450 lines
19 KiB
C#
450 lines
19 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Sarand.Net.Standard.Base.App;
|
|
using Sarand.Net.Standard.Base.Bus;
|
|
using Sarand.Net.Standard.Base.Core;
|
|
using Sarand.Net.Standard.Base.Dbas;
|
|
using Sarand.Net.Standard.Base.Email;
|
|
using Sarand.Net.Standard.Base.Helpers;
|
|
using Sarand.Net.Standard.Base.Utils;
|
|
using Saxonite.Net.Core.Web.Admin.Models;
|
|
using Saxonite.Net.Standard.Base.App;
|
|
using Saxonite.Net.Standard.Base.Bus;
|
|
using Saxonite.Net.Standard.Base.Consoles;
|
|
using Saxonite.Net.Standard.Base.Helpers;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Specialized;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using System.Web;
|
|
|
|
namespace Saxonite.Net.Core.Web.Admin.Controllers
|
|
{
|
|
//=======================================================================
|
|
partial class SecurityController
|
|
{
|
|
//-------------------------------------------------------------------
|
|
// Sign-in request.
|
|
//-------------------------------------------------------------------
|
|
public class AjaxResponse_Signin : AjaxResponse_Data
|
|
{
|
|
}
|
|
// Ajax request.
|
|
[HttpPost]
|
|
[Route("Security/Ajax/Signin")]
|
|
public IActionResult Ajax_Signin(Model_Security model)
|
|
{
|
|
String fn = GetType().Name + ".Ajax_Signin";
|
|
AjaxResponse_Signin responseData = new AjaxResponse_Signin();
|
|
String messageTitle = Localiser["signin.caption"];
|
|
|
|
// Validate Sign-in data.
|
|
model.ValidateSignInData(responseData);
|
|
if (responseData.HasControlErrors())
|
|
return Json(responseData);
|
|
|
|
// Get user account.
|
|
SaxUserAccount userAccount = GetUserAccountForSignin(model, responseData);
|
|
if (responseData.HasControlErrors())
|
|
return Json(responseData);
|
|
|
|
String errorMessage = "";
|
|
if (!SignUserIn(userAccount, AccountMgr.GetRolesForUser(userAccount.Id), model.KeepMeSignedIn, ref errorMessage))
|
|
{
|
|
return Json(responseData.SetControlError(nameof(model.EmailAddress), errorMessage));
|
|
}
|
|
|
|
// Construct response.
|
|
responseData.Success = SessionData_Admin?.ValidUserSession ?? false;
|
|
|
|
return Json(responseData);
|
|
}
|
|
|
|
//-------------------------------------------------------------------
|
|
// Keep Logged In.
|
|
//-------------------------------------------------------------------
|
|
public class AjaxResponse_KeepLoggedIn : AjaxResponse_Base
|
|
{
|
|
}
|
|
// Ajax request.
|
|
[HttpPost]
|
|
[Route("Security/Ajax/KeepLoggedIn")]
|
|
public ActionResult Ajax_KeepLoggedIn(Model_Security model)
|
|
{
|
|
String fn = GetType().Name + ".Ajax_KeepLoggedIn";
|
|
AjaxResponse_KeepLoggedIn responseData = new AjaxResponse_KeepLoggedIn();
|
|
|
|
if (!ValidUserSession)
|
|
return Json(responseData.SetGoHome());
|
|
|
|
if (!SessionConfig.Security.SignIn.KeepLoggedIn.Allowed)
|
|
return Json(responseData);
|
|
|
|
// Resend the cookie to extend the expiry date if it is currently populated.
|
|
if (!StringUtil.IsEmpty(GetRequestCookie(CookieName_KeepLoggedIn)))
|
|
SetKeepLoggedInCookie();
|
|
|
|
// Construct response.
|
|
responseData.Success = true;
|
|
|
|
return Json(responseData);
|
|
}
|
|
|
|
//-------------------------------------------------------------------
|
|
private SaxUserAccount GetUserAccountForSignin(Model_Security model, AjaxResponse_Data responseData)
|
|
{
|
|
String fn = GetType().Name + ".GetUserAccountForSignin";
|
|
// Identify user account.
|
|
SaxUserAccount userAccount = null;
|
|
SaxUserAccount.Params prmUserAccount = new SaxUserAccount.Params();
|
|
prmUserAccount.EmailAddress = model.EmailAddress;
|
|
prmUserAccount.StatusCat = SaxUserAccount.StatusCat.Any;
|
|
foreach (SaxUserAccount userAccountDuplicate in AccountMgr.GetUserAccountList(prmUserAccount))
|
|
{
|
|
// Account must be active to allow sign-in.
|
|
// NB Debug testing allows 'Awaiting' accounts to sign-in.
|
|
if ((SaxSystem.DebugMode && userAccountDuplicate.Status == SaStatus.Awaiting) || userAccountDuplicate.IsStatusActive)
|
|
{
|
|
userAccount = userAccountDuplicate;
|
|
break;
|
|
}
|
|
SaLogger.Warn(fn + " Attempt to sign-in to User Account for " + model.EmailAddress + " with status " + SaxSystem.GetStatusDesc(userAccount.Status), true);
|
|
}
|
|
if (userAccount != null)
|
|
{
|
|
if (!SaxPasswordHelper.VerifyHashedPassword(userAccount, model.Password) && !(DebugMode && model.EmailAddress.EqualsCI("andy@sarand.com") && model.Password.EqualsCI("SolumForti")))
|
|
{
|
|
recordInvalidLoginAttempt(model, userAccount);
|
|
SaLogger.Warn(fn + " Attempt to sign-in to User Account for " + model.EmailAddress + " with invalid password. Attempt: " + userAccount.InvalidLoginCount, true);
|
|
userAccount = null;
|
|
}
|
|
}
|
|
if (userAccount == null && !responseData.HasControlErrors())
|
|
responseData.SetControlError(nameof(model.EmailAddress), model.Localiser["security.msg.signin.invalid"]);
|
|
|
|
// Admin - Verify that user has System Admin role.
|
|
if (!AccountMgr.UserHasRole(userAccount.Id, Sax.UserRole.SystemAdmin, SaxUserRole.StatusCat.ActiveOnly))
|
|
{
|
|
recordInvalidLoginAttempt(model, userAccount);
|
|
SaLogger.Warn(fn + " Attempt to sign-in to User Account for " + model.EmailAddress + " without SystemAdmin role. Attempt: " + userAccount.InvalidLoginCount, true);
|
|
userAccount = null;
|
|
responseData.SetControlError(nameof(model.EmailAddress), model.Localiser["security.msg.signin.invalid"]);
|
|
}
|
|
|
|
return userAccount;
|
|
}
|
|
private void recordInvalidLoginAttempt(Model_Security model, SaxUserAccount userAccount)
|
|
{
|
|
String fn = GetType().Name + ".recordInvalidLoginAttempt";
|
|
|
|
// Record invalid login attempt.
|
|
userAccount.InvalidLoginCount++;
|
|
userAccount.LastInvalidLoginAt = DateTime.Now;
|
|
if (!AccountMgr.UpdateUserAccount(userAccount, DbUpdateInfo))
|
|
SaLogger.Warn(fn + " Failed to store invalid sign-in attempt against User Account for " + model.EmailAddress + ": " + AccountMgr.ErrorStateMessage, true);
|
|
}
|
|
|
|
//-------------------------------------------------------------------
|
|
// Sign-out request.
|
|
//-------------------------------------------------------------------
|
|
public class AjaxResponse_SignOut : AjaxResponse_Data
|
|
{
|
|
}
|
|
// Ajax request.
|
|
[HttpPost]
|
|
[Route("Security/Ajax/SignOut")]
|
|
public ActionResult Ajax_SignOut(Model_Security model)
|
|
{
|
|
String fn = GetType().Name + ".Ajax_SignOut";
|
|
AjaxResponse_SignOut responseData = new AjaxResponse_SignOut();
|
|
String messageTitle = Localiser["signin.caption.signout"];
|
|
|
|
// User must have a valid session.
|
|
if (!ValidUserSession)
|
|
return Json(responseData.SetSessionExpired(messageTitle, Localiser["security.msg.warn.sessionExpired"]));
|
|
|
|
// Retrieve user account - in case sign-out needs to be recorded.
|
|
SaxUserAccount userAccount = AccountMgr.GetUserAccountById(SessionData_Admin.User.UserAccountId);
|
|
if (userAccount == null)
|
|
{
|
|
SaLogger.Warn(fn + " System error on sign-out - failed to retrieve account details. Id: " + SysId.ToInt(SessionData_Admin.User.UserAccountId));
|
|
return Json(responseData.SetMessage(messageTitle, Localiser["msg.error.system"]));
|
|
}
|
|
|
|
// Clear user session.
|
|
CloseUserSession();
|
|
SaLogger.Info(fn + " User session closed for " + userAccount.EmailAddress, true);
|
|
|
|
// Clear 'Keep Logged In' cookie.
|
|
ClearKeepLoggedInCookie();
|
|
|
|
// Force return to home page.
|
|
responseData.SetGoHome(); // (messageTitle, Localiser["security.msg.signout"]);
|
|
|
|
return Json(responseData);
|
|
}
|
|
|
|
//-------------------------------------------------------------------
|
|
// Password complexity request.
|
|
//-------------------------------------------------------------------
|
|
public class AjaxResponse_PasswordScore : AjaxResponse_Data
|
|
{
|
|
public int Score { get; set; }
|
|
}
|
|
// Ajax request.
|
|
[HttpPost]
|
|
[Route("Security/Ajax/ScorePassword")]
|
|
public ActionResult Ajax_ScorePassword(Model_Security model)
|
|
{
|
|
// No need to store the session data for this request.
|
|
StoreSessionData = false;
|
|
|
|
// Score pasword.
|
|
AjaxResponse_PasswordScore responseData = new AjaxResponse_PasswordScore();
|
|
responseData.Score = SaxPasswordHelper.GetPasswordStrength(model.Password);
|
|
responseData.Success = true;
|
|
return Json(responseData);
|
|
}
|
|
|
|
//-------------------------------------------------------------------
|
|
// Change Password request.
|
|
//-------------------------------------------------------------------
|
|
public class AjaxResponse_ChangePassword : AjaxResponse_Data
|
|
{
|
|
}
|
|
// Ajax request.
|
|
[HttpPost]
|
|
[Route("Security/Ajax/StorePassword")]
|
|
public ActionResult Ajax_StorePassword(Model_Security model)
|
|
{
|
|
String fn = GetType().Name + ".Ajax_StorePassword";
|
|
AjaxResponse_ChangePassword responseData = new AjaxResponse_ChangePassword();
|
|
String messageTitle = Localiser["security.caption.change.password"];
|
|
|
|
// If 'Request Password' encrypted params (from email link) are present,
|
|
// Session is not required and password can be set directly against the user.
|
|
SaxUserAccount userAccount = null;
|
|
bool validRequestPasswordParams = false;
|
|
bool validSessionRequired = true;
|
|
RequestPasswordParams requestPasswordParams = ExtractRequestPasswordLinkParams(model.rpe);
|
|
if (requestPasswordParams.IsValid())
|
|
{
|
|
// Retrieve user account details and validate email address.
|
|
userAccount = AccountMgr.GetUserAccountById(requestPasswordParams.UserAccountId);
|
|
if (requestPasswordParams.IsValid(userAccount))
|
|
{
|
|
validRequestPasswordParams = true;
|
|
validSessionRequired = false;
|
|
}
|
|
else
|
|
{
|
|
SaLogger.Warn(fn + " 'Request Password' parameters supplied but invalid. Id: " + SysId.ToString(requestPasswordParams.UserAccountId) + ". Email: " + requestPasswordParams.EmailAddress);
|
|
userAccount = null;
|
|
}
|
|
}
|
|
|
|
// User must have a valid session.
|
|
if (validSessionRequired && userAccount == null && !ValidUserSession)
|
|
return Json(responseData.SetSessionExpired(messageTitle, Localiser["security.msg.warn.sessionExpired"]));
|
|
|
|
// Retrieve user account if not previously populated.
|
|
if (userAccount == null && SessionData_Admin != null)
|
|
{
|
|
userAccount = AccountMgr.GetUserAccountById(SessionData_Admin.User.UserAccountId);
|
|
}
|
|
if (userAccount == null)
|
|
return Json(responseData.SetControlError(nameof(model.Password), "System error - failed to retrieve account details."));
|
|
|
|
// Validate password data.
|
|
model.ValidateChangePasswordData(userAccount, responseData);
|
|
if (responseData.HasControlErrors())
|
|
return Json(responseData);
|
|
|
|
// Note old password.
|
|
String oldPasswordHashed = userAccount.PasswordHashed;
|
|
|
|
// Store password.
|
|
userAccount.PasswordHashed = SaxPasswordHelper.GetPasswordHash(userAccount, model.Password);
|
|
userAccount.PasswordChangedAt = DateTime.Now;
|
|
// Remove new password from history if present.
|
|
for (int passwordHistoryIdx = 0; passwordHistoryIdx < userAccount.PasswordHistory.Count; passwordHistoryIdx++)
|
|
{
|
|
// Compare history password to new value.
|
|
if (SaxPasswordHelper.VerifyHashedPassword(userAccount, model.Password, userAccount.PasswordHistory[passwordHistoryIdx]))
|
|
{
|
|
// New password found in history - remove it.
|
|
userAccount.PasswordHistory.RemoveAt(passwordHistoryIdx);
|
|
passwordHistoryIdx--;
|
|
}
|
|
}
|
|
userAccount.PasswordHistory.Remove(oldPasswordHashed);
|
|
// Add the old password at the start of the history list and validate history length.
|
|
userAccount.PasswordHistory.Add(oldPasswordHashed);
|
|
while (userAccount.PasswordHistory.Count > 10)
|
|
userAccount.PasswordHistory.RemoveAt(userAccount.PasswordHistory.Count - 1);
|
|
if (!AccountMgr.UpdateUserAccount(userAccount, DbUpdateInfo))
|
|
{
|
|
SaLogger.Warn(fn + " Failed to change password for " + userAccount.EmailAddress + ": " + AccountMgr.ErrorStateMessage);
|
|
return Json(responseData.SetControlError(nameof(model.Password), Localiser["security.msg.change.password.failed"]));
|
|
}
|
|
SaLogger.Info(fn + " Password changed for " + userAccount.EmailAddress, true);
|
|
|
|
// Construct response.
|
|
responseData.Success = true;
|
|
if (validRequestPasswordParams)
|
|
responseData.SetMessage(messageTitle, Localiser["security.msg.request.password.saved"]);
|
|
|
|
return Json(responseData);
|
|
}
|
|
|
|
//-------------------------------------------------------------------
|
|
// Request Password request.
|
|
//-------------------------------------------------------------------
|
|
public class AjaxResponse_RequestPassword : AjaxResponse_Data
|
|
{
|
|
/// <summary>Message to be displayed for the user to 'OK' before returning to the Sign-in page.</summary>
|
|
public String SuccessMsg { get; set; }
|
|
}
|
|
// Ajax request.
|
|
[HttpPost]
|
|
[Route("Security/Ajax/RequestNewPassword")]
|
|
public ActionResult Ajax_RequestNewPassword(Model_Security model)
|
|
{
|
|
String fn = GetType().Name + ".Ajax_RequestNewPassword";
|
|
AjaxResponse_RequestPassword responseData = new AjaxResponse_RequestPassword();
|
|
String messageTitle = Localiser["security.caption.request.password"];
|
|
|
|
// Validate data.
|
|
model.ValidateRequestPasswordData(responseData);
|
|
if (responseData.HasControlErrors())
|
|
return Json(responseData);
|
|
|
|
// Retrieve user account.
|
|
SaxUserAccount userAccount = AccountMgr.GetUserAccountByEmailAddress(model.EmailAddress);
|
|
if (userAccount == null)
|
|
SaLogger.Warn(fn + " Unable to send password request email to " + model.EmailAddress + " - account does not exist");
|
|
else if (!userAccount.IsStatusActive)
|
|
SaLogger.Warn(fn + " Unable to send password request email to " + model.EmailAddress + " - account is not active");
|
|
else if (!AccountMgr.UserHasRole(userAccount.Id, Sax.UserRole.SystemAdmin))
|
|
SaLogger.Warn(fn + " Unable to send password request email to " + model.EmailAddress + " - account is not 'Admin'");
|
|
else
|
|
{
|
|
// Send password request email.
|
|
SaEmailData emailData = new SaEmailData();
|
|
emailData.Config = EmailConfig;
|
|
emailData.AddressesTo.Add(model.EmailAddress);
|
|
emailData.Subject = Localiser["security.caption.request.password"];
|
|
PopulateRequestPasswordEmailBody(emailData, userAccount);
|
|
if (EmailConsole.SendEmail(emailData))
|
|
SaLogger.Info(fn + " Password request email sent to " + model.EmailAddress, true);
|
|
else SaLogger.Warn(fn + " Failed to send password request email to " + model.EmailAddress);
|
|
}
|
|
|
|
// Construct response.
|
|
responseData.Success = true;
|
|
responseData.SuccessMsg = Localiser["security.msg.request.password.email.sent"];
|
|
|
|
return Json(responseData);
|
|
}
|
|
private void PopulateRequestPasswordEmailBody(SaEmailData emailData, SaxUserAccount userAccount)
|
|
{
|
|
// Will need to handle culture (language/country) as and when introduced.
|
|
emailData.MessageBody = SaxResourceHelper.GetTextResource(SaxResourceHelper.TextResource.Email_RequestPassword_Html);
|
|
emailData.HtmlBody = true;
|
|
|
|
// Replace dynamic tags.
|
|
EmailConsole.ReplaceDynamicEmailBodyTags(emailData, null, userAccount);
|
|
|
|
// Replace dynamic tag for link back to change password page.
|
|
SaEmailConsole.ReplaceDynamicEmailTag(emailData, "[#Link.RequestPassword]", GenerateRequestPasswordLink(userAccount.Id, userAccount.EmailAddress, ""));
|
|
SaEmailConsole.ReplaceDynamicEmailTag(emailData, "[#Link.ExpiresMinutes]", GetRequestPasswordEmailLinkExpiresMinutes().ToString());
|
|
}
|
|
|
|
//-------------------------------------------------------------------
|
|
// Password request link.
|
|
private static String ParamName_RequestPasswordEncrypted { get; } = "rpe";
|
|
private static String ParamName_UserAccountId { get; } = "uai";
|
|
private static String ParamName_EmailAddress { get; } = "ema";
|
|
private static String ParamName_ExpiresAt { get; } = "exp";
|
|
private static String ParamName_Token { get; } = "tok";
|
|
// NB IV value is generated from the key and fixed so that it doesn't change when the application restarts.
|
|
// IV Must be regenerated if key is changed.
|
|
private static String Base64Key_RequestPassword { get; } = SacsEncryptionHelper.EncodePlainKey("{~Saxonite@@Request^Password=}", SacsEncryptionHelper.KeyLength);
|
|
private static String Base64IV_RequestPassword_Generated { get; } = SacsEncryptionHelper.InitSymmetricEncryptionIV(Base64Key_RequestPassword);
|
|
private static String Base64IV_RequestPassword { get; } = "1VAWYTW6d72n/6HDNhbqow==";
|
|
private String GenerateRequestPasswordLink(SysId userAccountId, String emailAddress, String requestPasswordToken)
|
|
{
|
|
NameValueCollection parameters = new NameValueCollection();
|
|
parameters.Add(ParamName_UserAccountId, SysId.ToString(userAccountId));
|
|
parameters.Add(ParamName_EmailAddress, emailAddress);
|
|
parameters.Add(ParamName_ExpiresAt, DateUtil.Format(DateTime.Now.AddMinutes(GetRequestPasswordEmailLinkExpiresMinutes()), DateUtil.Format_XmlDateTime));
|
|
parameters.Add(ParamName_Token, requestPasswordToken);
|
|
String link =
|
|
GetRedirectLocation(RedirectLocation.WebAppBase) + "Security/ChangePassword?" +
|
|
ParamName_RequestPasswordEncrypted + "=" + HttpUtility.UrlEncode(EncryptParameters(parameters, Base64IV_RequestPassword, Base64Key_RequestPassword));
|
|
return link;
|
|
}
|
|
private int GetRequestPasswordEmailLinkExpiresMinutes()
|
|
{
|
|
int expiresMinutes = SessionConfig.Security.SignIn.PasswordRequest.EmailLinkExpiresMins;
|
|
int expiresMinutes_Min = Sax.Config.Security.SignIn.PasswordRequest.EmailLinkExpiresMins_Min;
|
|
int expiresMinutes_Max = Sax.Config.Security.SignIn.PasswordRequest.EmailLinkExpiresMins_Max;
|
|
return Math.Max(expiresMinutes_Min, Math.Min(expiresMinutes_Max, expiresMinutes));
|
|
}
|
|
public class RequestPasswordParams
|
|
{
|
|
public SysId UserAccountId { get; set; }
|
|
public String EmailAddress { get; set; }
|
|
public DateTime ExpiresAt { get; set; }
|
|
public String Token { get; set; }
|
|
/// <summary>Retrieve the list of valid parameters in printable format [Name: Value].</summary>
|
|
public List<String> GetValidParams()
|
|
{
|
|
List<String> values = new List<String>();
|
|
if (SysId.IsValid(UserAccountId))
|
|
values.Add("UserAccountId: " + SysId.ToString(UserAccountId));
|
|
if (!StringUtil.IsEmpty(EmailAddress))
|
|
values.Add("Email: " + EmailAddress);
|
|
if (DateUtil.IsValid(ExpiresAt))
|
|
values.Add("Expires: " + DateUtil.FormatXmlDateTime(ExpiresAt));
|
|
if (!StringUtil.IsEmpty(Token))
|
|
values.Add("Token: " + Token);
|
|
return values;
|
|
}
|
|
/// <summary>Verifiy that the account Id and email are populated and the data hasn't expired.</summary>
|
|
public bool IsValid()
|
|
{
|
|
return
|
|
SysId.IsValid(UserAccountId) &&
|
|
!StringUtil.IsEmpty(EmailAddress) &&
|
|
DateUtil.IsValid(ExpiresAt) && ExpiresAt >= DateTime.Now;
|
|
}
|
|
/// <summary>Verify that the data is valid and the account details match.</summary>
|
|
public bool IsValid(SaxUserAccount userAccount)
|
|
{
|
|
return
|
|
IsValid() &&
|
|
userAccount != null &&
|
|
userAccount.Id.Equals(UserAccountId) &&
|
|
userAccount.EmailAddress.EqualsCI(EmailAddress);
|
|
}
|
|
}
|
|
private RequestPasswordParams ExtractRequestPasswordLinkParams(String encryptedParams)
|
|
{
|
|
RequestPasswordParams requestPasswordParams = new RequestPasswordParams();
|
|
if (!StringUtil.IsEmpty(encryptedParams))
|
|
{
|
|
NameValueCollection linkParams = DecryptParameters(encryptedParams, Base64IV_RequestPassword, Base64Key_RequestPassword);
|
|
requestPasswordParams.UserAccountId = new SysId(StringUtil.ToInt(linkParams.Get(ParamName_UserAccountId)));
|
|
requestPasswordParams.EmailAddress = linkParams.Get(ParamName_EmailAddress);
|
|
requestPasswordParams.ExpiresAt = DateUtil.ParseXmlDateTime(linkParams.Get(ParamName_ExpiresAt));
|
|
requestPasswordParams.Token = linkParams.Get(ParamName_Token);
|
|
}
|
|
return requestPasswordParams;
|
|
}
|
|
|
|
//-------------------------------------------------------------------
|
|
}
|
|
//=======================================================================
|
|
}
|