SuperFinance Digital Banking SaaS: Step-4 – Advance Security Controls (Account Suspension, IP Firewall, Two-Factor, XSS Prevention and Email Verification)

Introduction

As part of this tutorial, we’re building SuperFinance – a digital banking SaaS using ASPSecurityKit. In prior steps, we’ve learnt about SF’s features, type of users, security mechanisms to be employed, data models, permissions as well as building a self-service portal for banking institutions and customers to setup and manage banks, staff, accounts, nominees and perform transactions.

In this step, we’ll build the workflows or interfaces to implement advance security controls such as account suspension, IP firewall, two-factor authentication, cross-site scripting (XSS) prevention and email verification leveraging the features of the ASPSecurityKit security pipeline, to further harden the system.

We’ve already gone through the access or authorization controls applied with every feature we had built in prior steps. In this step also, wherever necessary, ADA checks will be used to authorize access to actions being built to manage these advance security controls.

Suspension

As discussed in the intro > suspension section, SuperFinance leverages entity suspension feature to implement the requirement of restricting access on bank accounts when the status is changed to non-active. Entity suspension restricts access to actions (which are otherwise permitted to the users) because the entity is in a suspended state. The access isn’t restricted for all kinds of users – this is where the exclusion rules come into play, which let you define one or more rules to relax the restrictions on certain operations and/or for specific users.

Relaxations

We are supporting following account status values: PendingApproval, Active, KYCRequired, Dormant, Freezd, Closed.

Except Active, we’ll use these status values as reason of suspension.

We shall create rules to implement following relaxations on suspended accounts: (note: as mentioned above, when we say “all” or “everyone” in below rules, we refer to only all such users who are already permitted on these accounts, as determined by ADA; there’s no additional privileges these exclusion rules are granting on data to any user which she doesn’t have already.)

  • Allow all data retrieval actions to everyone (of course when permitted; handled separately by ADA).
  • Allow deposit action to all staff roles except when the suspension reason is Closed. Obviously we can’t accept deposits on closed accounts.
  • Allow BankOwner to lift any type of suspension except when the suspension reason is Closed. SF doesn’t support reopening of closed accounts.
  • Allow BranchManager to lift PendingApproval, KYCRequired and Dormant suspensions.
  • Allow BranchStaff to lift KYCRequired suspension.

Things Required

With Premium source package, we’ve already got entity suspension components such as the SuspensionManager and SuspendedEntity/SuspensionExclusionRule data models. Only following things are required to make it work for SuperFinance needs:

  • Declare the exclusion rules as per the relaxations (allowed actions) mentioned above.
  • Add/remove suspended entity record when the account status changes.
  • Logic in ErrorController to show custom error page for account suspension.
  • Account suspension error view.

Exclusion Rules Declaration

View Code
    private void InsertSuspensionRules(MigrationBuilder migrationBuilder)
    {
     migrationBuilder.Sql(@"insert into SuspensionExclusionRule
(Id, EntityTypePattern, SuspensionTypePattern, VerbPattern, OperationPattern, PossessesAnyOfThePermissions)
 values
 --Can view = all get operations permitted on suspended entities regardless of reason/user role. But not MVC view retrieval actions which are really the post ones.
(newid(), 'Account', '.*', 'GET', '^(?:(?<!ChangeStatus|Deposit|Withdrawal|Transfer).)*$', null),
 --list* operations are also get only but called with post verb by jtable etc.
(newid(), 'Account', '.*', 'POST', 'List.*', null),
 --deposit allowed to all on suspended entity regardless of reason/user role (except on close accounts). Note - only staff is allowed but that's handled by permit authorization (ADA).
(newid(), 'Account', '^(?:(?<!close).)*$', '.*', 'Deposit', null),
--transfer/withdrawal is not allowed on pendingApproval. so allowing only deposit.
      (newid(), 'Account', 'PendingApproval', '.*', 'Deposit', null),
 --changeStatus allowed to owner on any status other than close.
(newid(), 'Account', '^(?:(?<!close).)*$', '.*', 'ChangeStatus', 'BankOwner'),
 --changeStatus allowed to manager on dormant/pendingApproval.
(newid(), 'Account', 'Dormant|PendingApproval', '.*', 'ChangeStatus', 'BranchManager'),
 --changeStatus allowed to manager/staff on KYC required.
(newid(), 'Account', 'KYCRequired', '.*', 'ChangeStatus', 'BranchManager|BranchStaff')");
    }

View Raw File

ChangeAccountStatusAsync

ChangeAccountStatusAsync
public async Task ChangeAccountStatusAsync(Guid id, AccountStatus status, string reason)
{
	var dbAccount = await this.dbContext.Accounts.FindAsync(id).ConfigureAwait(false);
	if (dbAccount != null)
	{
		if (status != dbAccount.Status)
		{
			if (suspendedAccountStatuses.Contains(dbAccount.Status))
			{
				await this.suspensionManager.DeleteEntityAsync(id);
			}

			if (suspendedAccountStatuses.Contains(status))
			{
				await this.suspensionManager.AddEntityAsync(new SuspendedEntity(id, "Account", status.ToString()));
			}
		}

		dbAccount.Status = status;
		dbAccount.Reason = reason;
		await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
	}
	else
	{
		throw new OpException(OpResult.DoNotExist);
	}
}

View Raw File

  • As you can see, we’re calling SuspensionManager.DeleteEntityAsync to remove an entity suspension record if the old status was one of the suspended values (basically not Active).
  • Further, we’re calling SuspensionManager.AddEntityAsync to add a record if the new status is one of the suspended values. This insertion is enough for the suspension workflow to apply suspension rules.

IP Firewall

Leveraging ASPSecurityKit’s IP firewall checks, Superfinance gives banks an ability to restrict access to the system by staff from only white-listed networks (or IP ranges). Customers also have an option to enable firewall from the profile.

With Premium source package, we’ve already got the complete implementation of firewall management at user-level; to make it work at the bank-level for staff users instead, we just need the following things:

Models

Bank
public class DbBank
{
	[Key]
	public Guid Id { get; set; }

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

	public string Address { get; set; }

	public bool FirewallEnabled { get; set; }

	public bool EnforceMFA { get; set; }

	public bool SkipMFAInsideNetwork { get; set; }

	public int? PasswordExpiresInDays { get; set; }

	[ForeignKey("User")]
	public Guid OwningUserId { get; set; }
	public DbUser OwningUser { get; set; }

	public IList<DbBranch> Branches { get; set; }

	public IList<DbAccountType> AccountTypes { get; set; }
}

View Raw File

Load Bank Firewall White-List for the Staff Users in IdentityRepository

View Code
if (dbSession.User.UserType == UserType.Staff && auth is SFIdentityAuthDetails authDetails)
{
	var owningUserId = dbSession.User.ParentId ?? dbSession.UserId;
	var dbBank = await dbContext.Banks.FirstOrDefaultAsync(x => x.OwningUserId == owningUserId)
		.ConfigureAwait(false);

	if (dbBank != null)
	{
		authDetails.BankId = dbBank.Id;
		authDetails.MFAEnforced = dbBank.EnforceMFA;
		skipMFAInsideNetwork = dbBank.SkipMFAInsideNetwork;
		firewallEnabled = dbBank.FirewallEnabled;
		entityUrn = EntityUrn.MakeUrn(SFEntityTypes.Bank, dbBank.Id);
	}
}
else
{
	firewallEnabled = dbSession.User.FirewallEnabled;
	entityUrn = EntityUrn.MakeUrn(EntityTypes.User, dbSession.User.Id);
}

if (firewallEnabled && !string.IsNullOrEmpty(entityUrn))
{
	auth.FirewallIpRanges = await this.dbContext.FirewallRules
		.Where(x => x.EntityUrn == entityUrn)
		.OrderBy(p => p.Name)
		.Select(x => new FirewallIpRange
		{
			Id = x.Id,
			Name = x.Name,
			IpFrom = x.IpFrom,
			IpTo = x.IpTo
		})
		.ToListAsync<IFirewallIpRange>().ConfigureAwait(false);
}
else
{
	auth.FirewallIpRanges = FirewallIpRange.RangeForWholeOfInternet();
}

View Raw File

  • We’re loading the bank’s firewall IPRanges if the user is a staff and firewall is enabled.

BankManager

SetFirewallStatusAsync
public async Task SetFirewallStatusAsync(Guid? bankId, bool enabled)
{
	var dbBank = await this.dbContext.Banks
		.Where(m => m.Id == bankId)
		.SingleOrDefaultAsync()
		.ConfigureAwait(false);

	if (dbBank == null)
	{
		await this.logger.WarnAsync("{0} doesn't exist", bankId).ConfigureAwait(false);
		throw new OpException(OpResult.DoNotExist);
	}

	if (enabled)
	{
		if (!await this.dbContext.FirewallRules
			.AnyAsync(x => x.EntityUrn == EntityUrn.MakeUrn(SFEntityTypes.Bank, dbBank.Id))
			.ConfigureAwait(false))
		{
			await this.logger.WarnAsync("No IP ranges has been found for bank {0}", bankId).ConfigureAwait(false);
			throw new OpException(OpResult.Failed, Messages.CannotEnableFirewall);
		}
	}

	dbBank.FirewallEnabled = enabled;
	await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}

View Raw File

  • We’re rejecting turn on request if the white-list is empty, to avoid accidental lock-out.

BankController

FirewallStatus
[PossessesPermissionCode]
public ActionResult FirewallStatus()
{
	return RedirectToAction("Index", new { id = ManageBankActionId.SetFirewallStatus });
}

[PossessesPermissionCode]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> FirewallStatus(bool enabled)
{
	await this.bankManager.SetFirewallStatusAsync(this.userService.BankId, enabled);
	return RedirectWithMessage("Index", SFMessages.FirewallStatusIsChanged, OpResult.Success);
}

View Raw File

UIs

Showing bank firewall IP ranges management page
Showing bank firewall status page

Two-Factor Authentication (2FA)

As discussed in the intro > two-factor section, SuperFinance implements a simple email-based 2FA mechanism by leveraging ASPSecurityKit’s Multi-Factor authentication (MFA) feature.

Requirements

  • If 2FA is enabled, prompt user to enter a security code sent on user’s email upon login.
  • Until user successfully verifies with 2FA, she cannot access any protected action/page (even by directly entering its URL) other than the 2FA prompt page.
  • 2FA is optional for customers and staff by default.
  • Bank can enforce 2FA on staff in which case staff is required to enable and verify with 2FA before they can get access to the system. Staff cannot opt out of 2FA in such a case.
  • Bank can decide to skip 2FA for staff if they’re operating from a white-listed network specified with the bank’s IP firewall settings.

Things Required

With Premium source package, we’ve already got the complete implementation of email-based 2FA workflow; to provide banks with enforcement related options, we just need the following things:

Models

SetMFAPolicyModel
public class SetMFAPolicyModel
{
	[Display(Name = "Enforce Two-Factor Authentication ?")]
	[Required]
	public bool EnforceMFA { get; set; }

	[Display(Name = "Skip Two-Factor Authentication check inside bank network?")]
	public bool SkipMFAInsideNetwork { get; set; }
}

View Raw File

BankManager

SetMFAPolicyAsync
public async Task SetMFAPolicyAsync(Guid? bankId, bool enforce, bool skipMFAInsideNetwork)
{
	var dbBank = await this.dbContext.Banks
		.Where(m => m.Id == bankId)
		.SingleOrDefaultAsync()
		.ConfigureAwait(false);

	if (dbBank == null)
	{
		await this.logger.WarnAsync("{0} doesn't exist", bankId).ConfigureAwait(false);
		throw new OpException(OpResult.DoNotExist);
	}

	dbBank.EnforceMFA = enforce;
	dbBank.SkipMFAInsideNetwork = skipMFAInsideNetwork;
	await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}

View Raw File

SFUserManager

AddStaffUserAsync
public async Task<AppUser> AddStaffUserAsync(AppUser user, string verificationUrl, string contactUrl)
{
	var result = await AddUserAsync(user, verificationUrl, contactUrl).ConfigureAwait(false);
	if (result != null)
	{
		await SetMFAIfEnforcedAsync(user.Username).ConfigureAwait(false);

		if (this.userService.IsAuthenticated)
		{
			// reload permissions to include permission on newly added user entity.
			await this.userService.RefreshPermissionsAsync().ConfigureAwait(false);
		}
	}

	return result;
}
SetMFAIfEnforcedAsync
private async Task SetMFAIfEnforcedAsync(string username)
{
	var dbUser = await this.dbContext.Users
		.Where(x => x.Username == username)
		.Include(x => x.MultiFactors)
		.SingleOrDefaultAsync()
		.ConfigureAwait(false);

	var dbBank = await this.dbContext.Banks.Where(x => x.Id == this.userService.BankId)
		.SingleOrDefaultAsync().ConfigureAwait(false);

	dbUser.UserType = UserType.Staff;

	if (dbBank != null)
	{
		dbUser.MultiFactors.First().Enabled = dbBank.EnforceMFA;
	}

	await this.dbContext.SaveChangesAsync().ConfigureAwait(false);
}

View Raw File

  • We’re automatically enabling 2FA for the new staff user if MFA is being enforced by the bank.

GetAuth Changes to Load Bank’s MFA Settings

View Code
if (dbSession.User.UserType == UserType.Staff && auth is SFIdentityAuthDetails authDetails)
{
	var owningUserId = dbSession.User.ParentId ?? dbSession.UserId;
	var dbBank = await dbContext.Banks.FirstOrDefaultAsync(x => x.OwningUserId == owningUserId)
		.ConfigureAwait(false);

	if (dbBank != null)
	{
		authDetails.BankId = dbBank.Id;
		authDetails.MFAEnforced = dbBank.EnforceMFA;
		skipMFAInsideNetwork = dbBank.SkipMFAInsideNetwork;
		firewallEnabled = dbBank.FirewallEnabled;
		entityUrn = EntityUrn.MakeUrn(SFEntityTypes.Bank, dbBank.Id);
	}
}
else
{
	firewallEnabled = dbSession.User.FirewallEnabled;
	entityUrn = EntityUrn.MakeUrn(EntityTypes.User, dbSession.User.Id);
}

if (firewallEnabled && !string.IsNullOrEmpty(entityUrn))
{
	auth.FirewallIpRanges = await this.dbContext.FirewallRules
		.Where(x => x.EntityUrn == entityUrn)
		.OrderBy(p => p.Name)
		.Select(x => new FirewallIpRange
		{
			Id = x.Id,
			Name = x.Name,
			IpFrom = x.IpFrom,
			IpTo = x.IpTo
		})
		.ToListAsync<IFirewallIpRange>().ConfigureAwait(false);
}
else
{
	auth.FirewallIpRanges = FirewallIpRange.RangeForWholeOfInternet();
}

if (skipMFAInsideNetwork)
{
	auth.MFAWhiteListedIpRanges = auth.FirewallIpRanges;
}

View Raw File

  • We’re setting MFA white-listed IP networks using the same firewall white-list (which bank configures to restrict user access) only if bank has chosen to skip MFA for such networks. However, this only works when bank also has firewall enabled.

BankController

MFAPolicy
[PossessesPermissionCode]
public ActionResult MFAPolicy()
{
	return RedirectToAction("Index", new { id = ManageBankActionId.SetMFAPolicy });
}

[PossessesPermissionCode]
[HttpPost, ValidateAntiForgeryToken]
public async Task<ActionResult> MFAPolicy(SetMFAPolicyModel model)
{
	if (this.ModelState.IsValid)
	{
		await this.bankManager.SetMFAPolicyAsync(this.userService.BankId, model.EnforceMFA, model.SkipMFAInsideNetwork);
		return RedirectWithMessage("Index", SFMessages.MFAPolicyIsChanged, OpResult.Success);
	}

	return await ManageView(ManageBankActionId.SetMFAPolicy, mModel: model);
}

View Raw File

UIs

Showing bank 2FA policy page
Showing user 2FA setting page
Showing 2FA prompt page

Cross-Site Scripting (XSS)

ASK provides out-of-the-box support for detecting and denying requests having potential XSS injection, which is enabled by default for .NET Core.

For the sanitization of dynamic content in email, we’re leveraging the template builder came with the Premium source package.

Email Verification

Email address is the username in Superfinance. Thus we require user to verify the email before continuing further with the system. We’re leveraging as is, the email verification workflow that came with the Premium source package.

Don't Miss Out!

Be the first to get notified when the new quality content related to web app security like the one you're reading is posted.