Step 2: SuperCRM requirements and build credential-based authentication operations
In this article
Note
This is a continuation step of the Getting started with ASK on ServiceStack 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 theUsername
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 ASPSecurityKitFeature plugin
The ASPSecurityKitFeature is a ServiceStack’s plugin that installs a global async requestFilter that preempts incoming requests and subjects them to the various checks of the security pipeline.
This is how the plugin is registered in the ASPSecurityKitConfiguration class (included in the template):
public static void Configure(IAppHost appHost, Container funqContainer)
{
...
appHost.Plugins.Add(new ASPSecurityKitFeature());
...
}
Important
Since ASK is based on Zero Trust security principle, all the security checks are applicable to every service operation by default. Various options are provided to opt out one or more checks from one or more operations 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.
SignUp operation
To keep it simple, for SignUp
operation we need a Request DTO to capture person name, email, password and type of account (individual/team) and the business name if type selected as team.
SignUp Request DTO
Add the following code as SignUp.cs
into the folder Request\Self
:
using ASPSecurityKit.ServiceStack;
using ServiceStack;
using SuperCRM.Models;
using SuperCRM.Response;
using System.Runtime.Serialization;
namespace SuperCRM.Request.Self
{
public enum AccountType
{
Individual,
Team,
}
[DataContract]
[Route("/sign-up", "POST")]
[AllowAnonymous]
public class SignUp : IReturn<BaseRecordResponse<AppUserDetails>>
{
[DataMember]
public string Name { get; set; }
[DataMember]
public string Email { get; set; }
[DataMember]
public string Password { get; set; }
[DataMember]
public bool RememberMe { get; set; }
[DataMember]
public AccountType Type { get; set; }
[DataMember]
public string BusinessName { get; set; }
}
}
We’ve used AllowAnonymousAttribute here as well as on SignIn
DTO later on this page because both 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.
SignUp validator
Add the following code as SignUpValidator.cs
into the folder Validators\Self
:
using ServiceStack.FluentValidation;
using SuperCRM.Request.Self;
namespace SuperCRM.Validators.Self
{
public class SignUpValidator : AbstractValidator<SignUp>
{
public SignUpValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(60);
RuleFor(x => x.Email)
.NotEmpty()
.MaximumLength(128)
.EmailAddress();
RuleFor(x => x.Password)
.NotEmpty()
.MaximumLength(512)
.MinimumLength(6);
RuleFor(x => x.BusinessName)
.NotEmpty()
.When(x => x.Type == AccountType.Team)
.WithMessage("Business Name is required.")
.MaximumLength(128);
}
}
}
We’re setting bunch of validations including required fields (NotEmpty), maximum lenght, email address (to be used as username) and lastly requiring business name if type of account is Team
.
SignUp operation
Add the following code as SelfService.cs
into the folder ServiceInterface
:
using ASPSecurityKit;
using ASPSecurityKit.ServiceStack;
using SuperCRM.DataModels;
using SuperCRM.Request.Self;
using SuperCRM.Response;
using System;
using System.Threading.Tasks;
namespace SuperCRM.ServiceInterface
{
public class SelfService : ServiceBase
{
private readonly IAuthSessionProvider authSessionProvider;
public SelfService(IUserService<Guid, Guid, DbUser> userService,
IAuthSessionProvider authSessionProvider, IConfig config,
IServiceStackSecuritySettings securitySettings)
: base(userService, securitySettings, config)
{
this.authSessionProvider = authSessionProvider;
}
public async Task<BaseResponse> Post(SignUp request)
{
var dbUser = await this.UserService.NewUserAsync(request.Email, request.Password, request.Name);
dbUser.Id = Guid.NewGuid();
if (request.Type == AccountType.Team)
dbUser.BusinessDetails = new DbBusinessDetails { Name = request.BusinessName };
if (await this.UserService.CreateAccountAsync(dbUser))
{
var result = await this.authSessionProvider.LoginAsync(request.Email, request.Password, false, this.SecuritySettings.LetSuspendedAuthenticate);
return Ok(PopulateCurrentUserDetails(result));
}
return Error(AppOpResult.UsernameAlreadyExists, "An account with this email is already registered.");
}
}
}
We first 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.
Note
You see use of BaseResponse
, Error
, Ok
, AppOpResult
etc. – these are elements part of ASK’s ServiceStack 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 and SignOut request DTOs
A service model for SignIn
accepts regular fields while SignOut
just needs an empty DTO.
Add the following code as SignIn.cs
into the folder Request\Self
:
using ASPSecurityKit;
using ASPSecurityKit.ServiceStack;
using ServiceStack;
using SuperCRM.Models;
using SuperCRM.Response;
using System.Runtime.Serialization;
namespace SuperCRM.Request.Self
{
[DataContract]
[Route("/sign-in", "POST")]
[AllowAnonymous]
public class SignIn : IReturn<BaseRecordResponse<AppUserDetails>>
{
[DataMember]
public string Email { get; set; }
[DataMember]
public string Password { get; set; }
[DataMember]
public bool RememberMe { get; set; }
}
[DataContract]
[Route("/sign-out", "POST")]
[Feature(ApplyTo.Post, RequestFeature.AuthorizationNotRequired, RequestFeature.MFANotRequired)]
public class SignOut : IReturn<BaseResponse>
{
}
}
We’ve already discussed about AllowAnonymousAttribute in the section about SignUp operation, Here we see a use of FeatureAttribute on SignOut
DTO. 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
.
SignIn Validator
Add the following code as SignInValidator.cs
into the folder Validators\Self
:
using ServiceStack.FluentValidation;
using SuperCRM.Request.Self;
namespace SuperCRM.Validators.Self
{
public class SignInValidator : AbstractValidator<SignIn>
{
public SignInValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress();
RuleFor(x => x.Password)
.NotEmpty();
}
}
}
The operations
Add the following code to the SelfService
class:
...
public async Task<BaseResponse> Post(SignIn request)
{
var result = await this.authSessionProvider.LoginAsync(request.Email, request.Password, request.RememberMe, false)
.ConfigureAwait(false);
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.");
}
}
public async Task<BaseResponse> Post(SignOut request)
{
await this.authSessionProvider.LogoutAsync().ConfigureAwait(false);
return Ok();
}
...
In the SignIn
operation, 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.
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 ServiceStack’s 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 in ServiceStack – 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 ServiceStack considers the platform as API and hence errors are always written as json to the response.
Tip
Starter and Premium source packages come with error handling routine 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 routine comes in a source form – so you can easily customize it further if needed – and subscribes to the ServiceStack’s unhandled exception hooks to implement graceful and responsible error-handling for production and development workloads.
Extending OpResult with custom error codes
As part of your ServiceStack 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 Request DTO 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-ServiceStack.ASPSecurityKit.net to play with a live demo built based on this tutorial.