Step 2: SuperCRM requirements and build credential-based authentication operations

Note

This is a continuation step of the Getting started with ASK on ASP.NET Core MVC walkthrough. To see the concepts explained here in action, copy over the code blocks below to the relevant files/folders of the SuperCRM project developed in the earlier step, or else you can get the previous step code from here.

SuperCRM entities

A customer relationship management (CRM) software is primarily used to manage contacts (customers) and the interactions with them. There are many other features on top of this, but we’ll limit this tutorial to these two entities for now. Here are two entities as EntityFramework Core code-first POCOs.

Add these classes to the file DataModels\DbModels.cs:

[Table("Contact")]
public class DbContact
{
	[Key]
	public Guid Id { get; set; }

	[Required]
	[MaxLength(128)]
	public string Name { get; set; }

	[ForeignKey("Owner")]
	public Guid OwnerId { get; set; }
	public DbUser Owner { get; set; }

	[MaxLength(15)]
	public string Phone { get; set; }

	[MaxLength(75)]
	public string Email { get; set; }

	[MaxLength(128)]
	public string Address1 { get; set; }

	[MaxLength(128)]
	public string Address2 { get; set; }

	[Required]
	[MaxLength(64)]
	public string AcquiredFrom { get; set; }

	public string Notes { get; set; }

	public DateTime CreatedDate { get; set; }

	[ForeignKey("CreatedBy")]
	public Guid CreatedById { get; set; }
	public DbUser CreatedBy { get; set; }

	public IList<DbInteraction> Interactions { get; set; }
}

public enum InteractionMethod
{
	Phone,
	Email,
	InPerson,
	Forum,
	SocialMedia,
	EmbeddedChat,
	Other
}

[Table("Interaction")]
public class DbInteraction
{
	[Key]
	public Guid Id { get; set; }

	[ForeignKey("Contact")]
	public Guid ContactId { get; set; }
	public DbContact Contact { get; set; }

	public InteractionMethod Method { get; set; }

	[MaxLength(256)]
	public string MethodDetails { get; set; }

	public string Notes { get; set; }

	public DateTime InteractionDate { get; set; }
	public DateTime CreatedDate { get; set; }

	[ForeignKey("CreatedBy")]
	public Guid CreatedById { get; set; }
	public DbUser CreatedBy { get; set; }
}

We have multi-tenancy in contact: a contact belongs to a user (OwnerId) – an owner of either an individual or team account in SuperCRM. In case of a team member creating a new contact, that’ll still be associated with the owner’s userId and not the team member’s userId – we have a separate CreatedById to record who exactly created the contact.

Interaction doesn’t directly require the OwnerId as it belongs to one by virtue of being associated with a contact.

The User type

The Essential source package (included in the template) has given you source code of data models, repositories and migrations, which includes an implementation of IUser – type representing the user account in the system. A portion of the same is shown below with the following addition for SuperCRM:

  • EmailAddress validation on the Username property. The email will act as username in SuperCRM.
  • A BusinessDetails property to capture business information such as its name, address etc. for the team account.
  • A new readonly property OwnerUserId which gives us the userId that owns this user. For individual accounts, this will always be self but for a team/business account, team members will belong to the user that signed up for the account and created those team users. We’ll leverage this property to retrieve contacts etc. for a user regardless of whether he is the owner or a team member.

Update the DbUser and add the DbBusinessDetails class in the file DataModels\DbModels.cs as follows:

[Table("User")]
public class DbUser : IUser<Guid>
{
	...

	[Required]
	[MaxLength(128)]
	[EmailAddress]
	public string Username { get; set; }

	...

	public Guid OwnerUserId => ParentId ?? Id;

	public DbBusinessDetails BusinessDetails { get; set; }

	...
}

[Table("BusinessDetails")]
public class DbBusinessDetails
{
	[Key, ForeignKey("User")]
	public Guid UserId { get; set; }

	public DbUser User { get; set; }

	[Required]
	[MaxLength(128)]
	public string Name { get; set; }

	[MaxLength(15)]
	public string Phone { get; set; }

	[MaxLength(128)]
	public string Address1 { get; set; }

	[MaxLength(128)]
	public string Address2 { get; set; }
}

As seen above, ASK’s interface-driven design has made it possible for you to define the user type as an EntityFramework’s table model, with not just regular validation attributes on the properties, but also how you want to treat username – in this case we treat it as email, but you can restrict it equally well to be a phone number or something else entirely. Also, you have the freedom to define the type of the primary key and can have additional properties you need in the same user type.

It also highlights the usefulness of getting these implementations as source code via the source packages – these are the things you usually need to customize to fit your project domain needs.

Visit source packages page to learn more about available packages and what each one contains.

Setup the database

a. Ensure the new entities are added to the AppDbContext class as DbSet properties as shown below:


public DbSet<DbBusinessDetails> BusinessDetails { get; set; }

public DbSet<DbContact> Contacts { get; set; }

public DbSet<DbInteraction> Interactions { get; set; }

b. Add EntityFramework migration for these data model changes and upon success, update the database to apply them (along with the security models' migration you’ve got with the template):

add-migration SuperCRMModels

...

update-database

The ProtectAttribute

ASK’s ProtectAttribute is the ASP.NET Core action filter that preempts incoming requests and subjects them to the various checks of its security pipeline. We recommend registering it as a global filter. The ASPSecurityKitConfiguration class – part of the template – does the same for you:

public static void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
	services.AddControllersWithViews(options =>
	{
		options.Filters.Add(typeof(ProtectAttribute));
	}) 
	...
}

Important

Since ASK is based on Zero Trust security principle, all the security checks are applicable to every MVC action by default. Various options are provided to opt out one or more checks from one or more actions as per your needs. We’ll be using some of those options in this tutorial as needed. To explore more in depth, visit the security pipeline article.

Create SignUp operation

To keep it simple, for SignUp operation we need a RegisterModel to capture person name, email, password and type of account (individual/team) and the business name if type selected as team.

RegisterModel

Add the following code as RegisterModel.cs into the folder Models:

using System.ComponentModel.DataAnnotations;

namespace SuperCRM.Models
{
	public enum AccountType
	{
		Individual,
		Team,
	}

	[CustomValidation(typeof(RegisterModel), nameof(RegisterModel.IsValid))]
	public class RegisterModel
	{
		[Required]
		[MaxLength(60)]
		[Display(Name = "Name")]
		public string Name { get; set; }

		[Required]
		[MaxLength(100)]
		[EmailAddress]
		[Display(Name = "Email")]
		public string Email { get; set; }

		[Required]
		[StringLength(512, MinimumLength = 6, ErrorMessage = "{0} must be between {1} and {2} characters.")]
		[DataType(DataType.Password)]
		[Display(Name = "Password")]
		public string Password { get; set; }

		[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
		[DataType(DataType.Password)]
		[Display(Name = "Confirm password")]
		public string ConfirmPassword { get; set; }

		[Display(Name = "Type of Account")]
		public AccountType Type { get; set; }

		[MaxLength(128)]
		[Display(Name = "Business Name")]
		public string BusinessName { get; set; }

		public static ValidationResult IsValid(RegisterModel model, ValidationContext context)
		{
			if (model.Type == AccountType.Team &&
				string.IsNullOrWhiteSpace(model.BusinessName))
				return new ValidationResult("Business Name is required");

			return ValidationResult.Success;
		}
	}
}

SignUp operation

Add the following code as UserController.cs into the folder Controllers:

using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;
using ASPSecurityKit;
using ASPSecurityKit.Net;
using SuperCRM.DataModels;
using SuperCRM.Models;

namespace SuperCRM.Controllers
{
	public class UserController : ServiceControllerBase
	{
		private readonly IAuthSessionProvider authSessionProvider;

		public UserController(IUserService<Guid, Guid, DbUser> userService, INetSecuritySettings securitySettings,
			ISecurityUtility securityUtility, ILogger logger, IAuthSessionProvider authSessionProvider) : base(userService, securitySettings, securityUtility,
			logger)
		{
			this.authSessionProvider = authSessionProvider;
		}

		[AllowAnonymous]
		public ActionResult SignUp()
		{
			return View();
		}

		[AllowAnonymous]
		[HttpPost, ValidateAntiForgeryToken]
		public async Task<ActionResult> SignUp(RegisterModel model)
		{
			if (ModelState.IsValid)
			{
				var dbUser = await this.UserService.NewUserAsync(model.Email, model.Password, model.Name);

				dbUser.Id = Guid.NewGuid();
				if (model.Type == AccountType.Team)
					dbUser.BusinessDetails = new DbBusinessDetails { Name = model.BusinessName };

				if (await this.UserService.CreateAccountAsync(dbUser))
				{
					await this.authSessionProvider.LoginAsync(model.Email, model.Password, false, this.SecuritySettings.LetSuspendedAuthenticate, true);

					return RedirectToAction("Index", "Home");
				}
				else
				{
					ModelState.AddModelError(string.Empty, "An account with this email is already registered.");
				}
				
			}

			// If we got this far, something failed, redisplay form
			return View(model);
		}

	}
}

If the input model has no validation error, we proceed to get a new user object by calling NewUser method. The method initializes several user object fields including HashedPassword (using salted PBKDF2-SHA1 hash algorithm).

Next, we set additional properties (including business details if account type is team) and then call CreateAccount to complete the user creation (the call fails if there’s already a user by the same email in the database).

Finally, we call login for the new user which creates a user session based AuthToken which is then added to the response as a cookie, so subsequent requests can authenticate using the same token.


Important

We’ve used AllowAnonymousAttribute for both SignUp (above) and SignIn (below) operations, as they do not need an authenticated request – they themselves are used to provide authentication details to establish the identity and issuing of a token that’ll be used by subsequent requests to authenticate.

However, AllowAnonymousAttribute only makes authentication optional for the operation; if an AuthToken is sent with a request to such an operation, the security pipeline does attempt to validate the token and establish the authentication if successful. This helps in showing the internal menu options, for example, to logged in user while they are on such public pages as docs, about, contact etc. which are otherwise accessible anonymously as well.


IUserService is the component that provides methods to authenticate, authorize and manage user identities.

IAuthSessionProvider exposes methods to manage sessions for identities – whether API Keys, user or anything else. ASK provides infrastructure for persistent sessions via IIdentityRepository so you can track and expire sessions for security reasons (for instance, upon reset password, it’s a good practice to expire any active session which ASK does). Additionally, these sessions support sliding expiration and MFA evidence.

ISecuritySettings defines configuration options to manage behavior of various checks of the security pipeline.

SignIn and SignOut operations

LoginModel

A view model for SignIn operation accepts regular fields:

(Add the following code as LoginModel.cs into the folder Models.)

using System.ComponentModel.DataAnnotations;

namespace SuperCRM.Models
{
	public class LoginModel
	{
		[Required]
		[EmailAddress]
		[Display(Name = "Email")]
		public string Email { get; set; }

		[Required]
		[DataType(DataType.Password)]
		[Display(Name = "Password")]
		public string Password { get; set; }

		[Display(Name = "Stay signed in?")]
		public bool RememberMe { get; set; }
	}
}

The operations

Add the following code to the UserController class:

[AllowAnonymous]
public ActionResult SignIn(string returnUrl)
{
	ViewBag.ReturnUrl = returnUrl;
	return View();
}

[AllowAnonymous]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> SignIn(LoginModel model, string returnUrl)
{
	if (ModelState.IsValid)
	{
		var result = await this.authSessionProvider.LoginAsync(model.Email, model.Password, model.RememberMe, this.SecuritySettings.LetSuspendedAuthenticate, true);
		switch (result.Result)
		{
			case OpResult.Success:
				// Only redirect to a local url and also, we should never redirect the user to sign-out automatically
				if (Url.IsLocalUrl(returnUrl) && !returnUrl.Contains("user/signout", StringComparison.InvariantCultureIgnoreCase))
				{
					return Redirect(returnUrl);
				}

				return RedirectToAction("Index", "Home");
			case OpResult.Suspended:
				ModelState.AddModelError(string.Empty, "This account is suspended.");
				break;
			case OpResult.PasswordBlocked:
				ModelState.AddModelError(string.Empty, "Your password is blocked. Please reset the password using the 'forgot password' option.");
				break;
			default:
				ModelState.AddModelError(string.Empty, "The email or password provided is incorrect.");
				break;
		}
	}

	// If we got this far, something failed, redisplay form
	return View(model);
}

[HttpPost, ValidateAntiForgeryToken]
[Feature(RequestFeature.AuthorizationNotRequired, RequestFeature.MFANotRequired)]
public async Task<ActionResult> SignOut()
{
	await this.authSessionProvider.LogoutAsync();
	return RedirectToAction("Index", "Home");
}

In the SignIn operation, if the input model has no validation error, we make a call to login which loads the existing user by the username provided and validates the credentials (using the configured IHashService – the default is salted PBKDF2).

Even if credentials are valid, a user can be in a suspended or password blocked state. In both of these cases, login fails and we’re reporting the same to the user.

If everything looks fine, it proceeds with loading the user details including permits into a new user session, generating an AuthToken from it. The token is then written to the response as a cookie, so subsequent requests can auto authenticate using the same.

In the SignOut operation, we call Logout, which clears the in-memory session, expires the current session and clears the AuthCookie (if there’s one).

Important

We’ve already discussed about AllowAnonymousAttribute in the section about SignUp operation, Here we see a use of FeatureAttribute on SignOut operation. The Feature attribute lets you opt out multiple security checks at once. Here we are opting SignOut out of both multi-factor and authorization steps of the security pipeline; in other words, only authentication is needed to call SignOut .


ASK provides you a consistent yet flexible way of communicating operation failures both internally and externally (to the caller) which you can use in other business portion of your application as well. Many methods in ASK return a response of type OpResult which is a sort of extendable Enum – built using a struct wrapping a string value. This is so you can return custom error codes while extending/customizing the security pipeline without resorting to workarounds (which you would have to in case of a pure enum). ASK also defines a OpResult-to-HttpStatusCode mapping, so it can be determined whether or not the error details to be shown to the caller. More about it in the later section on error handling.

The returnUrl parameter you see is something populated by ASK (specifically by MvcSecurityFailureResponseHandler – see the next section) to the SignIn URL when user tries to access a protected page without a valid AuthToken. Upon successful login, we use this to redirect user to the page originally requested.

Views and configuration

a. Add the below setting to the Configure method of the ASPSecurityKitConfiguration class. More on LoginUrl setting in the next section.

settings.LoginUrl = "/User/SignIn?returnUrl={0}";

b. Modify Index.html replacing SignIn & SignUp text with actual links:

<h4>Please @Html.ActionLink("Sign In", "SignIn", "User") if you have an account or else @Html.ActionLink("Sign Up", "SignUp", "User").</h4>

c. Modify _Layout.cshtml – add the SignIn & Sign Out menu links after the Home link:

...
<li class="nav-item @Html.IsSelected("Index", "Home")">
    <a class="nav-link" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
@if (Context.UserService().IsAuthenticated)
{
	<li class="nav-item ">
		<a class="nav-link" href="javascript:document.getElementById('logoutForm').submit()">Sign out</a>
		@using (Html.BeginForm("SignOut", "User", FormMethod.Post, new { id = "logoutForm" }))
		{
			@Html.AntiForgeryToken()
		}
	</li>
}
else {
	<li class="nav-item @Html.IsSelected("SignIn", "User")">
        <a class="nav-link" asp-area="" asp-controller="User" asp-action="SignIn">Sign In</a>
    </li>
}
...

d. Add SignUp and SignIn views under the Views\User folder:

@model RegisterModel
@{
    ViewBag.Title = "Sign Up";
}

<section class="form">
    @using (Html.BeginForm())
    {
        <h1> @ViewBag.Title </h1>
        <hr />

        @Html.AntiForgeryToken()

        <fieldset>
            <div class="form-group">
                @Html.LabelFor(m => m.Name, new { @class = "label-big" })
                @Html.TextBoxFor(m => m.Name, new { @class = "form-control", placeholder = "Please enter name" })
                @Html.ValidationMessageFor(m => m.Name)
            </div>

            <div class="form-group">
                @Html.LabelFor(m => m.Email, new { @class = "label-big" })
                @Html.TextBoxFor(m => m.Email, new { @class = "form-control", placeholder = "Please enter email" })
                <small class="form-text text-muted">We'll never share your email with anyone else.</small>
                @Html.ValidationMessageFor(m => m.Email)
            </div>

            <div class="form-group">
                @Html.LabelFor(m => m.Password, new { @class = "label-big" })
                @Html.PasswordFor(m => m.Password, new { @class = "form-control", placeholder = "Please enter password" })
                @Html.ValidationMessageFor(m => m.Password)
            </div>

            <div class="form-group">
                @Html.LabelFor(m => m.ConfirmPassword, new { @class = "label-big" })
                @Html.PasswordFor(m => m.ConfirmPassword, new { @class = "form-control", placeholder = "Please re-enter password" })
                @Html.ValidationMessageFor(m => m.ConfirmPassword)
            </div>

            <div class="form-group">
                @Html.LabelFor(m => m.Type, new { @class = "label-big" })
                @Html.DropDownListFor(m => m.Type, new List<SelectListItem> { new SelectListItem("Individual", "0", true), new SelectListItem("Team", "1") }, new { @id = "accountType", @class = "form-control", placeholder = "Please select accountType", @onchange = "accountTypeChanged()" })
                @Html.ValidationMessageFor(m => m.Type)
            </div>

            <div id="divBusiness" class="form-group">
                @Html.LabelFor(m => m.BusinessName, new { @class = "label-big" })
                @Html.TextBoxFor(m => m.BusinessName, new { @class = "form-control", placeholder = "Please enter business/organization name" })
                @Html.ValidationMessageFor(m => m.BusinessName)
            </div>

            <br />
            <br />

            <div class="form-group">
                <button type="submit" class="btn btn-primary">Submit</button>
            </div>

            <nav class="form-group">
                @Html.ActionLink("Sign In", "SignIn") if you do have an account.
            </nav>
        </fieldset>
    }
</section>

<input type="hidden" id="businessValue" value="1" />

@section scripts {
    <script type="text/javascript">

        $(document).ready(function() {
            accountTypeChanged();
        });

        function accountTypeChanged() {
            if ($('#accountType').val() === $('#businessValue').val()) {
                $('#divBusiness').show();
            } else {
                $('#divBusiness').hide();
            }
        }

    </script>
}
</section>
@model LoginModel

@{
    ViewBag.Title = "Sign In";
}
<section class="form">
    @using (Html.BeginForm(new { ReturnUrl = @ViewBag.ReturnUrl }))
    {
        <h1> @ViewBag.Title </h1>
        <hr />

        @Html.AntiForgeryToken()

        <fieldset>
            <div class="form-group">
                @Html.LabelFor(m => m.Email, new { @class = "label-big" })
                @Html.TextBoxFor(m => m.Email, new { @class = "form-control", placeholder = "Please enter email" })
                @Html.ValidationMessageFor(m => m.Email)
            </div>

            <div class="form-group">
                @Html.LabelFor(m => m.Password, new { @class = "label-big" })
                @Html.PasswordFor(m => m.Password, new { @class = "form-control", placeholder = "Please enter password" })
                @Html.ValidationMessageFor(m => m.Password)
            </div>

            <div class="form-control-static">
                @Html.CheckBoxFor(m => m.RememberMe)
                @Html.LabelFor(m => m.RememberMe)
            </div>

            <br />

            <div class="form-group">
                <button type="submit" class="btn btn-primary">Sign In</button>
            </div>

            <nav class="form-group">
                @Html.ActionLink("Sign Up", "SignUp") if you don't have an account.
            </nav>

        </fieldset>
    }
</section>

Auto redirecting user to SignIn page

If an operation – not opted out of authentication (using the AllowAnonymousAttribute) – is invoked without an AuthToken, the security pipeline disallows the request to proceed. From here the MvcSecurityFailureResponseHandler takes over and redirects the user to the SignIn page if configured in the security settings.

Modify the Configure method in the ASPSecurityKitConfiguration class, initializing the setting:

public static void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
	...
	settings.LoginUrl = "/User/SignIn?returnUrl={0}";
	...
}

Cross-site scripting (XSS) protection

ASK provides XSS detection as the first step of the security pipeline. Hence the XSS check is applicable even if the action is marked as AllowAnonymous. You can turn off XSS check by setting ValidateXss to false for the entire application (not recommended) or, better yet, opt out certain properties using AllowHtmlAttribute.

Note

XSS detection in the input is only one of the several measures that you can take to protect against cross-site scripting attack. Read this Guide on protecting against XSS to learn about other measures, including sanitizing data in emails, front-end, and by leveraging content security policy (CSP).

Get it without writing a line of code

Both Starter and Premium source packages come with SignUp and SignIn operations as source code, so you don’t have to write them yourself. These are generally more robust, modular and they handle other applicable cases as well. This includes sending verification email upon sign up, forbidding access to SignUp/SignIn actions if user is already logged in etc.

Download the sample

Refer the working sample project for this step of the tutorial on GitHub. Download the tutorial repo containing a stand-alone sample project for each step to try it locally on your PC.

Live demo

Visit https://SuperCRM-Mvc.ASPSecurityKit.net to play with a live demo built based on this tutorial.