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 Web API 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’s these 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 associate 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 associating 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.AddControllers(options =>
	{
		options.Filters.Add(typeof(ProtectAttribute));
	})
	...
}

Important

Since ASK is based on Zero Trust security principle, all the security checks are applicable to every API 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 SignUp model to capture person name, email, password and type of account (individual/team) and the business name if type selected as team.

SignUp Model

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

using System.ComponentModel.DataAnnotations;

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

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

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

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

		public AccountType Type { get; set; }

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

		public static ValidationResult IsValid(SignUp model, ValidationContext context)
		{
			if (model.Type == AccountType.Team &&
			    string.IsNullOrWhiteSpace(model.BusinessName))
				return new ValidationResult("BusinessName 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
{
	[ApiController]
	public class UserController : SiteControllerBase
	{
		private readonly IAuthSessionProvider authSessionProvider;

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

		[HttpPost]
		[AllowAnonymous]
		[Route("sign-up")]
		public async Task<BaseResponse> SignUp(SignUp 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))
				{
					var result = await this.authSessionProvider.LoginAsync(model.Email, model.Password, false, this.SecuritySettings.LetSuspendedAuthenticate);

					return Ok(PopulateCurrentUserDetails(result));
				}

				return Error(AppOpResult.UsernameAlreadyExists, "An account with this email is already registered.");
			}

			return Error();
		}		
	}
}

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 with sessionId and secret which are then returned in the response for the caller to use them to sign subsequent requests with HMAC 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 operation, the security pipeline does attempt to validate the token and establish the authentication if successful.

Note

You see use of BaseResponse, Error, Ok, AppOpResult etc. – these are elements part of ASK’s API template you’ve got. These utilities streamline secure and rapid development with proper, graceful error handling. starter and Premium source packages have even more extensive version of these routines, out of which a basic form has been extracted into the template we’re using for the tutorial.


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

SignIn model

A service model for SignIn operation accepts regular fields:

Add the following code as SignIn.cs into the folder Models.

using System.ComponentModel.DataAnnotations;

namespace SuperCRM.Models
{
	public class SignIn
	{
		[Required]
		[EmailAddress]
		public string Email { get; set; }

		[Required]
		public string Password { get; set; }

		public bool RememberMe { get; set; }
	}
}

The operations

Add the following code to the UserController class:

[HttpPost]
[AllowAnonymous]
[Route("sign-in")]
public async Task<BaseResponse> SignIn(SignIn model)
{
	if (ModelState.IsValid)
	{
		var result = await this.authSessionProvider.LoginAsync(model.Email, model.Password, model.RememberMe, this.SecuritySettings.LetSuspendedAuthenticate);
		switch (result.Result)
		{
			case OpResult.Success:
				return Ok(PopulateCurrentUserDetails(result));
			case OpResult.Suspended:
				return Error(result.Result, "This account is suspended.");
			case OpResult.PasswordBlocked:
				return Error(result.Result, "Your password is blocked. Please reset the password using the 'forgot password' option.");
			default:
				return Error(OpResult.InvalidInput, "The email or password provided is incorrect.");
		}
	}

	return Error();
}

[HttpPost]
[Feature(RequestFeature.AuthorizationNotRequired, RequestFeature.MFANotRequired)]
[Route("sign-out")]
public async Task<BaseResponse> SignOut()
{
	await this.authSessionProvider.LogoutAsync();
	return Ok();
}

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, whose sessionId and secret properties are returned in the response, so callers can use them to sign subsequent requests with HMAC token.

In the SignOut operation, we call Logout, which clears the in-memory session and expires the current session.

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)(/docs/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 next section below.

Zero Trust protection: handling ‘Unauthenticated’ and other security errors maturely

If an operation – not opted out of authentication (using the AllowAnonymousAttribute) – is invoked without an AuthToken, the security pipeline disallows the request to proceed. The pipeline hands over the error handling to ISecurityFailureResponseHandler.HandleFailure. If HandleFailure returns false (the default behavior for API requests requests in the built-in ASK handler) – indicating that it’s unable to handle the failure – the pipeline shall throw the failure as AuthFailedException if ThrowSecurityFailureAsException setting is true (false is the default); otherwise, it writes the error to the response.

It’s important to carefully consider what to reveal in the response when it comes to security errors. The environment is the primary consideration to limit error details to be revealed. Here reproducing the docs of WriteToResponse method – which is the default ASK’s error handler for API requests – to understand how ASK approaches this subject:

Regarding the information to be written, the default implementations follow the guidelines mentioned for OpException. Briefly speaking, HTTP status code is first determined using ISecurityUtility.OpResultToStatusCode and if it’s 500, IErrorMessageResourceProvider.InternalServerError is written to the response rather than the provided failure parameters.

However, if ISecuritySettings.IsDevelopmentEnvironment is true, the failure parameters are written to the response regardless of the status code.

The default implementations write errors in the json format on the API platforms. For mix platforms, the default implementations determine if the call is an API call; if it is so, write in the json format; otherwise, write in the plain text format.

Note

The default implementation for ASP.NET Core considers the platform as mix and hence errors are only written as json to the response if current request is determined as API. In other cases, it’s returned as text/plain content.

Tip

Starter and Premium source packages come with error handling middleware that extends the above approach to any error generated by your entire application so as to not reveal sensitive information to the callers on non-development environments. The middleware comes in a source form – so you can easily customize it further if needed.

Extending OpResult with custom error codes

As part of your API Template, under Infrastructure, you’ve got a type named AppOpResult. Here you can define custom error codes which will act as codes in OpResult. However, please note this is just a sample guidance we’re giving as part of the template and tutorial; there’s no automated code that reads AppOpResult at the moment to do an automatic wiring up – so feel free to organize custom error codes the way you want.

using ASPSecurityKit;

namespace SuperCRM
{
	public static class AppOpResult
	{
		// Add your custom error codes here. For example: 
		public const string UsernameAlreadyExists = nameof(UsernameAlreadyExists);
		public const string InvalidToken = nameof(InvalidToken);

		// Add these error codes to the OpResult to HTTP status code mapper so the generic error handling logic can determine whether or not the error message is sensitive. E.G., anything with status other than 500 is considered non-sensitive and is expected to be reported.
		// By default code not added to the mapper is considered sensitive (500 status code) – so you can just skip such codes and mapp only the ones you want to reveal with original error message. Check out docs for OpException to learn more about this approach.
		static AppOpResult()
		{
			foreach (var o in new[] { UsernameAlreadyExists, InvalidToken })
			{
				SecurityUtility.OpResultToHttpStatusCodeMapper.Add(o, 400);
			}
		}
	}
}

We will define and leverage custom error code in the next step.

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 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-WebApi.ASPSecurityKit.net to play with a live demo built based on this tutorial.