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. //------------------------------------------------------------------- private class AjaxResponse_Signin : AjaxResponse_Data { // Construction. public AjaxResponse_Signin(SaxModel_Admin 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 Sign-in data. model.ValidateSignInData(responseData); if (responseData.HasControlErrors()) return Json(responseData); // Record disposable items as they are created so all can be disposed when needed. using (DisposableCollection disposableItems = new DisposableCollection()) { // Get user account. SaxUserAccount userAccount = GetUserAccountForSignin(model, responseData); disposableItems.Add(userAccount); 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. //------------------------------------------------------------------- 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"; if (DebugMode) SaLogger.Debug(fn); AjaxResponse_KeepLoggedIn responseData = new AjaxResponse_KeepLoggedIn(); 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(KeepLoggedIn.CookieName))) SetKeepLoggedInCookie(); // Construct response. responseData.Success = true; return Json(responseData); } //------------------------------------------------------------------- private SaxUserAccount GetUserAccountForSignin(Model_Security model, AjaxResponse_Data responseData) { String fn = GetType().Name + ".GetUserAccountForSignin"; SaxUserAccount userAccount = null; // Record disposable items as they are created so all can be disposed when needed. using (DisposableCollection disposableItems = new DisposableCollection()) { // Identify user account. SaxUserAccount.Params prmUserAccount = new SaxUserAccount.Params(); prmUserAccount.EmailAddress = model.EmailAddress; prmUserAccount.StatusCat = SaxUserAccount.StatusCat.Any; List userAccounts = AccountMgr.GetUserAccountList(prmUserAccount); disposableItems.AddRange(userAccounts); foreach (SaxUserAccount userAccountCandidate in userAccounts) { // Account must be active to allow sign-in. // NB Debug testing allows 'Awaiting' accounts to sign-in. if ((SaxSystem.DebugMode && userAccountCandidate.Status == SaStatus.Awaiting) || userAccountCandidate.IsStatusActive) { userAccount = userAccountCandidate; break; } SaLogger.Warn(fn + " Attempt to sign-in to User Account for " + model.EmailAddress + " with status " + SaxSystem.GetStatusDesc(userAccount.Status), true); } if (userAccount != null) { // Validate password. 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; } // Admin - Verify that user has System Admin role. else 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; } } if (userAccount == null && !responseData.HasControlErrors()) 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. //------------------------------------------------------------------- private class AjaxResponse_SignOut : AjaxResponse_Data { // Construction. public AjaxResponse_SignOut(SaxModel_Admin 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"]; // User must have a valid session. if (!ValidUserSession) return Json(responseData.SetSessionExpired(messageTitle, Localiser["security.msg.warn.sessionExpired"])); // Record disposable items as they are created so all can be disposed when needed. using (DisposableCollection disposableItems = new DisposableCollection()) { // Retrieve user account - in case sign-out needs to be recorded. SaxUserAccount userAccount = AccountMgr.GetUserAccountById(SessionData_Admin.User.UserAccountId); disposableItems.Add(userAccount); 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. //------------------------------------------------------------------- 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) { // 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. //------------------------------------------------------------------- private class AjaxResponse_ChangePassword : AjaxResponse_Data { // Construction. public AjaxResponse_ChangePassword(SaxModel_Admin 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"]; // Record disposable items as they are created so all can be disposed when needed. using (DisposableCollection disposableItems = new DisposableCollection()) { // 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); disposableItems.Add(userAccount); 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); disposableItems.Add(userAccount); } 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. //------------------------------------------------------------------- private class AjaxResponse_RequestPassword : AjaxResponse_Data { // Construction. public AjaxResponse_RequestPassword(SaxModel_Admin 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/RequestNewPassword")] public ActionResult Ajax_RequestNewPassword(Model_Security model) { String fn = GetType().Name + ".Ajax_RequestNewPassword"; AjaxResponse_RequestPassword responseData = new AjaxResponse_RequestPassword(model); String messageTitle = Localiser["security.caption.request.password"]; // Validate data. model.ValidateRequestPasswordData(responseData); if (responseData.HasControlErrors()) return Json(responseData); // Record disposable items as they are created so all can be disposed when needed. using (DisposableCollection disposableItems = new DisposableCollection()) { // Retrieve user account. SaxUserAccount userAccount = AccountMgr.GetUserAccountByEmailAddress(model.EmailAddress); disposableItems.Add(userAccount); 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 { if (SendRequestPasswordEmail(this, userAccount, false)) 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); } public static bool SendRequestPasswordEmail(SaxController_Admin controller, SaxUserAccount userAccount, bool onlineContext) { // Send password request email. SaEmailData emailData = new SaEmailData(); emailData.Config = controller.EmailConfig; emailData.AddressesTo.Add(userAccount.EmailAddress); emailData.Subject = controller.Localiser["security.caption.request.password"]; PopulateRequestPasswordEmailBody(controller, emailData, userAccount, onlineContext); return controller.EmailConsole.SendEmail(emailData); } private static void PopulateRequestPasswordEmailBody(SaxController_Admin controller, SaEmailData emailData, SaxUserAccount userAccount, bool onlineContext) { // Will need to handle culture (language/country) as and when introduced. emailData.MessageBody = SaxResourceHelper.GetTextResource(SaxResourceHelper.TextResource.Email_Security_RequestPassword_Html); emailData.HtmlBody = true; // Replace dynamic tags. controller.EmailConsole.ReplaceDynamicEmailBodyTags(emailData, null, userAccount); // Replace dynamic tag for link back to change password page. SaEmailConsole.ReplaceDynamicEmailTag(emailData, "[#Link.RequestPassword]", GenerateRequestPasswordLink(controller, userAccount.Id, userAccount.EmailAddress, "", onlineContext)); SaEmailConsole.ReplaceDynamicEmailTag(emailData, "[#Link.ExpiresMinutes]", GetRequestPasswordEmailLinkExpiresMinutes(controller).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_Admin { get; } = SacsEncryptionHelper.EncodePlainKey("{~Saxonite@@Request^Password=}", SacsEncryptionHelper.KeyLength); //private static String Base64IV_RequestPassword_Generated { get; } = SacsEncryptionHelper.InitSymmetricEncryptionIV(Base64Key_RequestPassword); private static String Base64IV_RequestPassword_Admin { get; } = "1VAWYTW6d72n/6HDNhbqow=="; /// /// Generate Request Password link. /// NB For an Online application request (from teh ASdmin system), must ensure that the URL and encryption parameters are switched. /// internal static String GenerateRequestPasswordLink(SaxController_Admin controller, SysId userAccountId, String emailAddress, String requestPasswordToken, bool onlineContext) { // Base URL. String baseUrl = onlineContext ? controller.AppConfig.URL.Online.Root : controller.GetRedirectLocation(RedirectLocation.WebAppBase); String requestPassword_Base64Key = onlineContext ? Base64Key_RequestPassword_Online : Base64Key_RequestPassword_Admin; String requestPassword_ase64IV = onlineContext ? Base64IV_RequestPassword_Online : Base64IV_RequestPassword_Admin; 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(controller)), DateUtil.Format_XmlDateTime)); parameters.Add(ParamName_Token, requestPasswordToken); String link = baseUrl + "Security/ChangePassword?" + ParamName_RequestPasswordEncrypted + "=" + HttpUtility.UrlEncode(EncryptParameters(parameters, requestPassword_ase64IV, requestPassword_Base64Key)); return link; } internal static int GetRequestPasswordEmailLinkExpiresMinutes(SaxController_Admin controller) { int expiresMinutes = controller.AppConfig.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)); } private class RequestPasswordParams { public SysId UserAccountId { get; set; } public String EmailAddress { 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 (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; } /// Verifiy that the account Id and email are populated and the data hasn't expired. public bool IsValid() { return SysId.IsValid(UserAccountId) && !StringUtil.IsEmpty(EmailAddress) && DateUtil.IsValid(ExpiresAt) && ExpiresAt >= DateTime.Now; } /// Verify that the data is valid and the account details match. 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_Admin, Base64Key_RequestPassword_Admin); 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; } //------------------------------------------------------------------- } //======================================================================= }