SuperFinance Digital Banking SaaS: Step-4 – Advance Security Controls (Account Suspension, IP Firewall, Two-Factor, XSS Prevention and Email Verification)
In this article
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
andDormant
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')");
}
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);
}
}
- 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:
- FirewallEnabled property in Bank data model.
- Load bank firewall white-listed instead for the staff user’s in IdentityRepository.GetAuthAsync.
SetFirewallStatusAsync
method in BankManager.- FirewallStatus Actions in BankController.
- Bank firewall status view and hiding user-level Firewall setting for staff.
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; }
}
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();
}
- 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);
}
- 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);
}
UIs
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:
EnforceMFA
,SkipMFAInsideNetwork
properties in bank data model and SetMFAPolicy view model.SetMFAPolicyAsync
method in BankManager.- Logic to set 2FA enabled on new staff in SFUserManager if 2FA is being enforced by the bank.
- Logic to set MFAEnforced and MFA white-listed IP ranges in AuthDetails from the bank’s firewall white-list in IdentityRepository.GetAuthAsync.
- MFAPolicy Actions in BankController.
- Bank MFA policy form view.
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; }
}
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);
}
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);
}
- 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;
}
- 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);
}
UIs
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.
Try Out the Live Demo
Visit https://superfinance.ASPSecurityKit.net to play with a live demo based on this sample.