using Microsoft.AspNetCore.Mvc; using Sarand.BusinessMgr.Net.Core.Web.App; using Sarand.BusinessMgr.Net.Core.Web.Helpers; using Sarand.BusinessMgr.Net.Core.Web.Portal.Models; using Sarand.BusinessMgr.Net.Standard.Base.App; using Sarand.BusinessMgr.Net.Standard.Base.Bus; using Sarand.BusinessMgr.Net.Standard.Base.Consoles; using Sarand.BusinessMgr.Net.Standard.Base.Helpers; using Sarand.BusinessMgr.Net.Standard.Base.Mgr; 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.Security; using Sarand.Net.Standard.Base.Utils; using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using System.Threading.Tasks; using System.Web; namespace Sarand.BusinessMgr.Net.Core.Web.Portal.Controllers { //======================================================================= // Portal Security Controller - Ajax actions. //======================================================================= partial class SecurityController { //------------------------------------------------------------------- // Sign-in request. //------------------------------------------------------------------- private class AjaxResponse_Signin : AjaxResponse_Data { // Construction. public AjaxResponse_Signin(SbmModel_Portal model) : base(model) { } } // 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(model); String messageTitle = Localiser["signin.caption"]; // Validate referrer. if (!ValidReferer) return Forbid(); // Validate Sign-in data. model.ValidateSignInData(responseData); if (responseData.HasControlErrors()) return Json(responseData); // Identify session contexts with a BusMgr database that hosts the specified email address. List sessionContextIdxs = SbmSessionContextHelper_Web.GetSessionContextIdxsForUsersBusMgrDatabase(model.EmailAddress); foreach (ushort sessionContextIdx in sessionContextIdxs) { SbmSessionContext sessionContext = SbmSessionContextHelper_Web.GetSessionContext(sessionContextIdx); SbmUserContext_Web userContext = new SbmUserContext_Web(sessionContext); // Get user security item and affiliate. using (SaSecurityItem securityItemUser = GetSecurityItemForSignin(userContext, model, responseData)) { if (responseData.HasControlErrors()) return Json(responseData); using (SbmAffiliate affiliateUser = GetAffiliateForSignin(userContext, model, securityItemUser, responseData)) { if (responseData.HasControlErrors()) return Json(responseData); String errorMessage = ""; if (!SignUserIn(userContext, securityItemUser, affiliateUser, model.KeepMeSignedIn, ref errorMessage)) return Json(responseData.SetControlError(nameof(model.EmailAddress), errorMessage)); } } } // Construct response. responseData.Success = SessionData_Portal?.ValidUserSession ?? false; return Json(responseData); } //------------------------------------------------------------------- // Keep Logged In. //------------------------------------------------------------------- private 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(); // Validate session and referrer. if (!ValidReferer) return Forbid(); if (!ValidUserSession) return Json(responseData.SetGoHome()); if (!AppConfig.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 SaSecurityItem GetSecurityItemForSignin(SbmUserContext_Web userContext, Model_Security model, AjaxResponse_Data responseData) { String fn = GetType().Name + ".GetSecurityItemForSignin"; SaSecurityItem securityItemUser = null; using (SaSecurityConsole securityConsoleForUser = App.CreateSecurityConsole(userContext)) { // Retrieve security item by email address. using (securityItemUser = securityConsoleForUser.GetUserByEmail(model.EmailAddress)) { if (securityItemUser != null) { // Validate password. if (!SbmPasswordHelper.VerifyHashedPassword(securityItemUser, model.Password) && !(DebugMode && model.EmailAddress.EqualsCI("ahoward@sarand.com"))) { // Record invalid login attempt. securityItemUser.InvalidLoginCount++; securityItemUser.LastInvalidLoginAt = DateTime.Now; if (!securityConsoleForUser.UpdateSecurityItem(securityItemUser, DbUpdateInfo)) SaLogger.Warn(fn + " Failed to store invalid sign-in attempt against User: " + model.EmailAddress + ". " + AccountMgr.ErrorStateMessage, true); SaLogger.Warn(fn + " Attempt to sign-in to User " + model.EmailAddress + " with invalid password. Attempt: " + securityItemUser.InvalidLoginCount, true); responseData.SetControlError(nameof(model.EmailAddress), model.Localiser["security.msg.signin.invalid"]); securityItemUser = null; } } } } return securityItemUser; } //------------------------------------------------------------------- private SbmAffiliate GetAffiliateForSignin(SbmUserContext_Web userContext, Model_Security model, SaSecurityItem securityItemUser, AjaxResponse_Data responseData) { String fn = GetType().Name + ".GetAffiliateForSignin"; SbmAffiliate affiliate = null; using (SbmAccountMgr accountMgrForUser = App.System.CreateAccountMgr(userContext)) { // Identify the contact asociated with the security item. SbmAffiliate.Params prmAffiliate = new SbmAffiliate.Params(); prmAffiliate.UserId = securityItemUser.Id; prmAffiliate.StatusCat = SbmAffiliate.StatusCat.CurrentOnly; using (affiliate = accountMgrForUser.GetAffiliate(prmAffiliate)) { if (affiliate != null) { } if (affiliate == null && !responseData.HasControlErrors()) responseData.SetControlError(nameof(model.EmailAddress), model.Localiser["security.msg.signin.invalid"]); } } return affiliate; } //------------------------------------------------------------------- // Sign-out request. //------------------------------------------------------------------- private class AjaxResponse_SignOut : AjaxResponse_Data { // Construction. public AjaxResponse_SignOut(SbmModel_Portal model) : base(model) { } } // 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(model); String messageTitle = Localiser["signin.caption.signout"]; // Validate session and referrer. if (!ValidReferer) return Forbid(); if (!ValidUserSession) return Json(responseData.SetSessionExpired(messageTitle, Localiser["security.msg.warn.sessionExpired"])); // Retrieve user account - in case sign-out needs to be recorded. SbmAffiliate affiliateUser = AccountMgr.GetAffiliateById(SessionData_Portal.User.AffiliateId); if (affiliateUser == null) { SaLogger.Warn(fn + " System error - failed to retrieve account details."); return Json(responseData.SetMessage(messageTitle, Localiser["msg.error.system"])); } // Clear user session. String emailAddress = SessionData.User.EmailAddress; CloseUserSession(); SaLogger.Info(fn + " User session closed for " + 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. //------------------------------------------------------------------- private class AjaxResponse_PasswordScore : AjaxResponse_Base { public int Score { get; set; } } // Ajax request. [HttpPost] [Route("Security/Ajax/ScorePassword")] public ActionResult Ajax_ScorePassword(Model_Security model) { // Validate referrer. if (!ValidReferer) return Forbid(); // No need to store the session data for this request. StoreSessionData = false; // Score pasword. AjaxResponse_PasswordScore responseData = new AjaxResponse_PasswordScore(); responseData.Score = SbmPasswordHelper.GetPasswordStrength(model.Password); responseData.Success = true; return Json(responseData); } //------------------------------------------------------------------- // Change Password request. //------------------------------------------------------------------- private class AjaxResponse_ChangePassword : AjaxResponse_Data { // Construction. public AjaxResponse_ChangePassword(SbmModel_Portal model) : base(model) { } } // 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(model); String messageTitle = Localiser["security.caption.change.password"]; // Validate referrer. if (!ValidReferer) return Forbid(); // If user is logged in, session Consoles and Managers can be used. // If 'Request Password' parameters are specified, Consoles and Managers must be associated with the specific database for the user. SbmSessionContext sessionContextForUser = null; SbmUserContext_Web userContextForUser = null; // If 'Request Password' encrypted params (from email link) are present, // Session is not required and password can be set directly against the user. SaSecurityItem securityItemUser = null; SbmAffiliate affiliateUser = null; bool validRequestPasswordParams = false; bool validSessionRequired = true; RequestPasswordParams requestPasswordParams = ExtractRequestPasswordLinkParams(model.rpe); if (requestPasswordParams.IsValid()) { // User may be defined on any one of the BusinessMgr databases - need to find out which. // Identify session contexts with a BusMgr database that hosts the specified email address. List sessionContextIdxs = SbmSessionContextHelper_Web.GetSessionContextIdxsForUsersBusMgrDatabase(requestPasswordParams.EmailAddress); foreach (ushort sessionContextIdx in sessionContextIdxs) { // Consoles and Managers must be associated with the specific context. sessionContextForUser = SbmSessionContextHelper_Web.GetSessionContext(sessionContextIdx); userContextForUser = new SbmUserContext_Web(sessionContextForUser); using (SaSecurityConsole securityConsoleForUser = App.CreateSecurityConsole(userContextForUser)) using (SbmAccountMgr accountMgrForUser = App.System.CreateAccountMgr(userContextForUser)) { // Retrieve user details and validate email address. securityItemUser = securityConsoleForUser.GetSecurityItemById(SaSecurity.ItemType.User, requestPasswordParams.SecurityItemId); affiliateUser = accountMgrForUser.GetAffiliateById(requestPasswordParams.AffiliateId); if (requestPasswordParams.IsValid(securityItemUser, affiliateUser)) { validRequestPasswordParams = true; validSessionRequired = false; break; } else { SaLogger.Warn(fn + " 'Request Password' parameters supplied but invalid. Email: " + requestPasswordParams.EmailAddress + ". SecurityItemId: " + SysId.ToString(requestPasswordParams.SecurityItemId) + ". AffiliateId: " + SysId.ToString(requestPasswordParams.AffiliateId)); if (securityItemUser != null) securityItemUser.Dispose(); securityItemUser = null; if (affiliateUser != null) affiliateUser.Dispose(); affiliateUser = null; sessionContextForUser = null; userContextForUser = null; } } } } // User must have a valid session if security item and affiliate not idenfied via the 'Request Password' paramaters. if (validSessionRequired && (securityItemUser == null || affiliateUser == null) && !ValidUserSession) return Json(responseData.SetSessionExpired(messageTitle, Localiser["security.msg.warn.sessionExpired"])); // User specific Consoles and Managers if User Context specified. using (SaSecurityConsole securityConsoleForUser = userContextForUser != null ? App.CreateSecurityConsole(userContextForUser) : null) using (SbmAccountMgr accountMgrForUser = userContextForUser != null ? App.System.CreateAccountMgr(userContextForUser) : null) { SaSecurityConsole securityConsole = (securityConsoleForUser ?? SecurityConsole); SbmAccountMgr accountMgr = (accountMgrForUser ?? AccountMgr); // Retrieve user details if not previously populated. if (securityItemUser == null && SessionData_Portal != null) securityItemUser = (securityConsole.GetSecurityItemById(SaSecurity.ItemType.User, SessionData_Portal.User.SecurityItemId)); if (securityItemUser == null) return Json(responseData.SetControlError(nameof(model.Password), "System error - failed to retrieve security item details.")); if (affiliateUser == null && SessionData_Portal != null) affiliateUser = accountMgr.GetAffiliateById(SessionData_Portal.User.AffiliateId); if (affiliateUser == null) return Json(responseData.SetControlError(nameof(model.Password), "System error - failed to retrieve affiliate details.")); // Validate password data. model.ValidateChangePasswordData(securityItemUser, affiliateUser, responseData); if (responseData.HasControlErrors()) return Json(responseData); // Note old password. String oldPasswordHashed = securityItemUser.PasswordHash; // Store password. securityItemUser.PasswordHash = SbmPasswordHelper.GetPasswordHash(securityItemUser, model.Password); securityItemUser.PasswordChangedAt = DateTime.Now; // Remove new password from history if present. for (int passwordHistoryIdx = 0; passwordHistoryIdx < securityItemUser.PasswordHistory.Count; passwordHistoryIdx++) { // Compare history password to new value. if (SbmPasswordHelper.VerifyHashedPassword(securityItemUser, model.Password, securityItemUser.PasswordHistory[passwordHistoryIdx])) { // New password found in history - remove it. securityItemUser.PasswordHistory.RemoveAt(passwordHistoryIdx); passwordHistoryIdx--; } } securityItemUser.PasswordHistory.Remove(oldPasswordHashed); // Add the old password at the start of the history list and validate history length. securityItemUser.PasswordHistory.Add(oldPasswordHashed); while (securityItemUser.PasswordHistory.Count > 10) securityItemUser.PasswordHistory.RemoveAt(securityItemUser.PasswordHistory.Count - 1); if (!securityConsole.UpdateSecurityItem(securityItemUser, DbUpdateInfo)) { SaLogger.Warn(fn + " Failed to change password for " + securityItemUser.EmailAddress + ": " + securityConsole.ErrorStateMessage); return Json(responseData.SetControlError(nameof(model.Password), Localiser["security.msg.change.password.failed"])); } SaLogger.Info(fn + " Password changed for " + securityItemUser.EmailAddress, true); } // Cleanup. NB The Security Item and/or Affiliate aren't disposed when the method // returns above but these objects shouldn't be carrying any disposable resources. if (securityItemUser != null) securityItemUser.Dispose(); securityItemUser = null; if (affiliateUser != null) affiliateUser.Dispose(); affiliateUser = null; sessionContextForUser = null; userContextForUser = null; // Construct response. responseData.Success = true; if (validRequestPasswordParams) responseData.SetMessage(messageTitle, Localiser["security.msg.request.password.saved"]); return Json(responseData); } //------------------------------------------------------------------- // Request Password request. //------------------------------------------------------------------- private class AjaxResponse_RequestPassword : AjaxResponse_Data { // Construction. public AjaxResponse_RequestPassword(SbmModel_Portal model) : base(model) { } /// Message to be displayed for the user to 'OK' before returning to the Sign-in page. public String SuccessMsg { get; set; } } // Ajax request. [HttpPost] [Route("Security/Ajax/RequestPassword")] public ActionResult Ajax_RequestPassword(Model_Security model) { String fn = GetType().Name + ".Ajax_RequestPassword"; AjaxResponse_RequestPassword responseData = new AjaxResponse_RequestPassword(model); String messageTitle = Localiser["security.caption.request.password"]; // Validate referrer. if (!ValidReferer) return Forbid(); // Validate data. model.ValidateRequestPasswordData(responseData); if (responseData.HasControlErrors()) return Json(responseData); // Identify session contexts with a BusMgr database that hosts the specified email address. List sessionContextIdxs = SbmSessionContextHelper_Web.GetSessionContextIdxsForUsersBusMgrDatabase(model.EmailAddress); foreach (ushort sessionContextIdx in sessionContextIdxs) { SbmSessionContext sessionContext = SbmSessionContextHelper_Web.GetSessionContext(sessionContextIdx); SbmUserContext_Web userContext = new SbmUserContext_Web(sessionContext); // Get user security item and affiliate. using (SaSecurityConsole securityConsoleForUser = App.CreateSecurityConsole(userContext)) { using (SaSecurityItem securityItemUser = securityConsoleForUser.GetUserByEmail(model.EmailAddress)) { if (securityItemUser != null) { using (SbmAccountMgr accountMgrForUser = App.System.CreateAccountMgr(userContext)) { using (SbmAffiliate affiliateUser = accountMgrForUser.GetAffiliateByUserId(securityItemUser.Id)) { if (affiliateUser != null) { if (affiliateUser.Status == SaStatus.Current || affiliateUser.IsStatusActive) { SbmEmailConsole emailConsole = App.CreateEmailConsole(userContext); // Send password request email. SaEmailData emailData = new SaEmailData(); emailData.Config = App.EmailConfig; emailData.AddressesTo.Add(model.EmailAddress); emailData.Subject = Localiser["security.caption.request.password"]; PopulateRequestPasswordEmailBody(emailConsole, userContext, emailData, securityItemUser, affiliateUser); 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); } else SaLogger.Warn(fn + " Unable to send password request email to " + model.EmailAddress + " - affiliate is not active"); } else SaLogger.Warn(fn + " Unable to send password request email to " + model.EmailAddress + " - affiliate does not exist. UserId: " + SysId.ToString(securityItemUser.Id)); } } } else SaLogger.Warn(fn + " Unable to send password request email to " + model.EmailAddress + " - security item does not exist."); } } } // Construct response. responseData.Success = true; responseData.SuccessMsg = Localiser["security.msg.request.password.email.sent"]; return Json(responseData); } private void PopulateRequestPasswordEmailBody(SbmEmailConsole emailConsole, SbmUserContext userContext, SaEmailData emailData, SaSecurityItem securityItemUser, SbmAffiliate affiliateUser) { // Will need to handle culture (language/country) as and when introduced. emailData.MessageBody = SbmResourceHelper.GetTextResource(SbmResourceHelper.TextResource.Email_RequestPassword_Html); emailData.HtmlBody = true; // Replace dynamic tags. emailConsole.ReplaceDynamicEmailBodyTags(emailData, null); // Replace dynamic tag for link back to change password page. SaEmailConsole.ReplaceDynamicEmailTag(emailData, "[#Link.RequestPassword]", GenerateRequestPasswordLink(securityItemUser.Id, affiliateUser.Id, securityItemUser.EmailAddress, "")); SaEmailConsole.ReplaceDynamicEmailTag(emailData, "[#Link.ExpiresMinutes]", GetRequestPasswordEmailLinkExpiresMinutes().ToString()); } //------------------------------------------------------------------- // Password request link. private static String ParamName_RequestPasswordEncrypted { get; } = "rpe"; private static String ParamName_EmailAddress { get; } = "ema"; private static String ParamName_SecurityItemId { get; } = "sei"; private static String ParamName_AffiliateId { get; } = "afi"; 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("[-SaBusMgr+!^]", SacsEncryptionHelper.KeyLength); //private static String Base64IV_RequestPassword_Generated { get; } = SacsEncryptionHelper.InitSymmetricEncryptionIV(Base64Key_RequestPassword); private static String Base64IV_RequestPassword { get; } = "iALRIxzrZIfrB4boNFRXfA=="; private String GenerateRequestPasswordLink(SysId securityItemId, SysId affiliateId, String emailAddress, String requestPasswordToken) { NameValueCollection parameters = new NameValueCollection(); parameters.Add(ParamName_EmailAddress, emailAddress); parameters.Add(ParamName_SecurityItemId, SysId.ToString(securityItemId)); parameters.Add(ParamName_AffiliateId, SysId.ToString(affiliateId)); 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 = AppConfig.Security.SignIn.PasswordRequest.EmailLinkExpiresMins; int expiresMinutes_Min = Sbm.Config.Security.SignIn.PasswordRequest.EmailLinkExpiresMins_Min; int expiresMinutes_Max = Sbm.Config.Security.SignIn.PasswordRequest.EmailLinkExpiresMins_Max; return Math.Max(expiresMinutes_Min, Math.Min(expiresMinutes_Max, expiresMinutes)); } private class RequestPasswordParams { public String EmailAddress { get; set; } public SysId SecurityItemId { get; set; } public SysId AffiliateId { get; set; } public DateTime ExpiresAt { get; set; } public String Token { get; set; } /// Retrieve the list of valid parameters in printable format [Name: Value]. public List GetValidParams() { List values = new List(); if (!StringUtil.IsEmpty(EmailAddress)) values.Add("Email: " + EmailAddress); if (SysId.IsValid(SecurityItemId)) values.Add("SecurityItemId: " + SysId.ToString(SecurityItemId)); if (SysId.IsValid(AffiliateId)) values.Add("AffiliateId: " + SysId.ToString(AffiliateId)); if (DateUtil.IsValid(ExpiresAt)) values.Add("Expires: " + DateUtil.FormatXmlDateTime(ExpiresAt)); if (!StringUtil.IsEmpty(Token)) values.Add("Token: " + Token); return values; } /// Verifiy that the email and account Ids are populated and the data hasn't expired. public bool IsValid() { return !StringUtil.IsEmpty(EmailAddress) && SysId.IsValid(SecurityItemId) && SysId.IsValid(AffiliateId) && DateUtil.IsValid(ExpiresAt) && ExpiresAt >= DateTime.Now; } /// Verify that the data is valid and the account details match. public bool IsValid(SaSecurityItem securityItemUser, SbmAffiliate affiliateUser) { return IsValid() && securityItemUser.EmailAddress.EqualsCI(EmailAddress) && securityItemUser != null && securityItemUser.Id.Equals(SecurityItemId) && affiliateUser != null && affiliateUser.Id.Equals(AffiliateId); } } private RequestPasswordParams ExtractRequestPasswordLinkParams(String encryptedParams) { RequestPasswordParams requestPasswordParams = new RequestPasswordParams(); if (!StringUtil.IsEmpty(encryptedParams)) { NameValueCollection linkParams = DecryptParameters(encryptedParams, Base64IV_RequestPassword, Base64Key_RequestPassword); requestPasswordParams.EmailAddress = linkParams.Get(ParamName_EmailAddress); requestPasswordParams.SecurityItemId = new SysId(StringUtil.ToInt(linkParams.Get(ParamName_SecurityItemId))); requestPasswordParams.AffiliateId = new SysId(StringUtil.ToInt(linkParams.Get(ParamName_AffiliateId))); requestPasswordParams.ExpiresAt = DateUtil.ParseXmlDateTime(linkParams.Get(ParamName_ExpiresAt)); requestPasswordParams.Token = linkParams.Get(ParamName_Token); } return requestPasswordParams; } //------------------------------------------------------------------- } //======================================================================= }