From 1cdc80e09b37299c7356c4dea959d0c88f4909a1 Mon Sep 17 00:00:00 2001 From: Shaun Walker Date: Thu, 3 Mar 2022 09:12:37 -0500 Subject: [PATCH] 2 factor authentication and user account lockout completed --- Oqtane.Client/Modules/Admin/Login/Index.razor | 341 ++++++++++-------- .../Modules/Admin/UserProfile/Index.razor | 194 +++++----- .../Resources/Modules/Admin/Login/Index.resx | 46 ++- .../Modules/Admin/UserProfile/Index.resx | 6 + .../Services/Interfaces/IUserService.cs | 8 + Oqtane.Client/Services/UserService.cs | 5 + Oqtane.Server/Controllers/UserController.cs | 123 +++++-- .../OqtaneServiceCollectionExtensions.cs | 6 +- .../EntityBuilders/BaseEntityBuilder.cs | 74 +++- .../Tenant/03010002_AddUserTwoFactor.cs | 33 ++ .../Oqtane.Modules.Admin.Login/Module.css | 6 +- Oqtane.Shared/Models/User.cs | 21 +- 12 files changed, 561 insertions(+), 302 deletions(-) create mode 100644 Oqtane.Server/Migrations/Tenant/03010002_AddUserTwoFactor.cs diff --git a/Oqtane.Client/Modules/Admin/Login/Index.razor b/Oqtane.Client/Modules/Admin/Login/Index.razor index 9a1eb15d..ec418fd4 100644 --- a/Oqtane.Client/Modules/Admin/Login/Index.razor +++ b/Oqtane.Client/Modules/Admin/Login/Index.razor @@ -7,10 +7,6 @@ @inject IStringLocalizer Localizer @inject IStringLocalizer SharedLocalizer -@if (_message != string.Empty) -{ - -} ... @@ -19,179 +15,208 @@
@Localizer["Info.SignedIn"]
-
- -
-
+ @if (!twofactor) + { +
+ +
+ } + else + { +
+ +
+ } +
@code { - private string _returnUrl = string.Empty; - private string _message = string.Empty; - private MessageType _type = MessageType.Info; - private string _username = string.Empty; - private string _password = string.Empty; - private bool _remember = false; - private bool validated = false; + private ElementReference login; + private bool validated = false; + private bool twofactor = false; + private string _username = string.Empty; + private ElementReference username; + private string _password = string.Empty; + private bool _remember = false; + private string _code = string.Empty; - private ElementReference login; - private ElementReference username; + private string _returnUrl = string.Empty; - public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Anonymous; + public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Anonymous; - public override List Resources => new List() + public override List Resources => new List() { new Resource { ResourceType = ResourceType.Stylesheet, Url = ModulePath() + "Module.css" } }; - protected override async Task OnInitializedAsync() - { - if (PageState.QueryString.ContainsKey("returnurl")) - { - _returnUrl = PageState.QueryString["returnurl"]; - } + protected override async Task OnInitializedAsync() + { + if (PageState.QueryString.ContainsKey("returnurl")) + { + _returnUrl = PageState.QueryString["returnurl"]; + } - if (PageState.QueryString.ContainsKey("name")) - { - _username = PageState.QueryString["name"]; - } + if (PageState.QueryString.ContainsKey("name")) + { + _username = PageState.QueryString["name"]; + } - if (PageState.QueryString.ContainsKey("token")) - { - var user = new User(); - user.SiteId = PageState.Site.SiteId; - user.Username = _username; - user = await UserService.VerifyEmailAsync(user, PageState.QueryString["token"]); + if (PageState.QueryString.ContainsKey("token")) + { + var user = new User(); + user.SiteId = PageState.Site.SiteId; + user.Username = _username; + user = await UserService.VerifyEmailAsync(user, PageState.QueryString["token"]); - if (user != null) - { - await logger.LogInformation(LogFunction.Security, "Email Verified For For Username {Username}", _username); - _message = Localizer["Success.Account.Verified"]; - } - else - { - await logger.LogError(LogFunction.Security, "Email Verification Failed For Username {Username}", _username); - _message = Localizer["Message.Account.NotVerfied"]; - _type = MessageType.Warning; - } - } - } + if (user != null) + { + await logger.LogInformation(LogFunction.Security, "Email Verified For For Username {Username}", _username); + AddModuleMessage(Localizer["Success.Account.Verified"], MessageType.Info); + } + else + { + await logger.LogError(LogFunction.Security, "Email Verification Failed For Username {Username}", _username); + AddModuleMessage(Localizer["Message.Account.NotVerfied"], MessageType.Warning); + } + } + } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - if(PageState.User == null) - { - await username.FocusAsync(); - } - } - } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && PageState.User == null) + { + await username.FocusAsync(); + } + } - private async Task Login() - { - validated = true; - var interop = new Interop(JSRuntime); - if (await interop.FormValid(login)) - { - if (PageState.Runtime == Oqtane.Shared.Runtime.Server) - { - var user = new User(); - user.SiteId = PageState.Site.SiteId; - user.Username = _username; - user.Password = _password; - user = await UserService.LoginUserAsync(user, false, false); + private async Task Login() + { + validated = true; + var interop = new Interop(JSRuntime); + if (await interop.FormValid(login)) + { + var user = new User { SiteId = PageState.Site.SiteId, Username = _username, Password = _password}; - if (user.IsAuthenticated) - { - await logger.LogInformation(LogFunction.Security, "Login Successful For Username {Username}", _username); - // server-side Blazor needs to post to the Login page so that the cookies are set correctly - var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, username = _username, password = _password, remember = _remember, returnurl = _returnUrl }; - string url = Utilities.TenantUrl(PageState.Alias, "/pages/login/"); - await interop.SubmitForm(url, fields); - } - else - { - await logger.LogInformation(LogFunction.Security, "Login Failed For Username {Username}", _username); - AddModuleMessage(Localizer["Error.Login.Fail"], MessageType.Error); - } - } - else - { - // client-side Blazor - var user = new User(); - user.SiteId = PageState.Site.SiteId; - user.Username = _username; - user.Password = _password; - user = await UserService.LoginUserAsync(user, true, _remember); - if (user.IsAuthenticated) - { - await logger.LogInformation(LogFunction.Security, "Login Successful For Username {Username}", _username); - var authstateprovider = (IdentityAuthenticationStateProvider)ServiceProvider.GetService(typeof(IdentityAuthenticationStateProvider)); - authstateprovider.NotifyAuthenticationChanged(); - NavigationManager.NavigateTo(NavigateUrl(_returnUrl, true)); - } - else - { - await logger.LogInformation(LogFunction.Security, "Login Failed For Username {Username}", _username); - AddModuleMessage(Localizer["Error.Login.Fail"], MessageType.Error); - } - } - } - else - { - AddModuleMessage(Localizer["Message.Required.UserInfo"], MessageType.Warning); - } - } + if (!twofactor) + { + user = await UserService.LoginUserAsync(user, false, false); + } + else + { + user = await UserService.VerifyTwoFactorAsync(user, _code); + } - private void Cancel() - { - NavigationManager.NavigateTo(_returnUrl); - } + if (user.IsAuthenticated) + { + await logger.LogInformation(LogFunction.Security, "Login Successful For Username {Username}", _username); - private async Task Forgot() - { - if (_username != string.Empty) - { - var user = await UserService.GetUserAsync(_username, PageState.Site.SiteId); - if (user != null) - { - await UserService.ForgotPasswordAsync(user); - await logger.LogInformation(LogFunction.Security, "Password Reset Notification Sent For Username {Username}", _username); - _message = Localizer["Message.ForgotUser"]; - } - else - { - _message = Localizer["Message.UserDoesNotExist"]; - _type = MessageType.Warning; - } - } - else - { - _message = Localizer["Message.ForgotPassword"]; - } + if (PageState.Runtime == Oqtane.Shared.Runtime.Server) + { + // server-side Blazor needs to post to the Login page so that the cookies are set correctly + var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, username = _username, password = _password, remember = _remember, returnurl = _returnUrl }; + string url = Utilities.TenantUrl(PageState.Alias, "/pages/login/"); + await interop.SubmitForm(url, fields); + } + else + { + var authstateprovider = (IdentityAuthenticationStateProvider)ServiceProvider.GetService(typeof(IdentityAuthenticationStateProvider)); + authstateprovider.NotifyAuthenticationChanged(); + NavigationManager.NavigateTo(NavigateUrl(_returnUrl, true)); + } + } + else + { + if (user.TwoFactorRequired) + { + twofactor = true; + validated = false; + AddModuleMessage(Localizer["Message.TwoFactor"], MessageType.Info); + } + else + { + if (!twofactor) + { + await logger.LogInformation(LogFunction.Security, "Login Failed For Username {Username}", _username); + AddModuleMessage(Localizer["Error.Login.Fail"], MessageType.Error); + } + else + { + await logger.LogInformation(LogFunction.Security, "Two Factor Verification Failed For Username {Username}", _username); + AddModuleMessage(Localizer["Error.TwoFactor.Fail"], MessageType.Error); + } + } + } + } + else + { + AddModuleMessage(Localizer["Message.Required.UserInfo"], MessageType.Warning); + } + } - StateHasChanged(); - } + private void Cancel() + { + NavigationManager.NavigateTo(_returnUrl); + } + + private async Task Forgot() + { + if (_username != string.Empty) + { + var user = await UserService.GetUserAsync(_username, PageState.Site.SiteId); + if (user != null) + { + await UserService.ForgotPasswordAsync(user); + await logger.LogInformation(LogFunction.Security, "Password Reset Notification Sent For Username {Username}", _username); + AddModuleMessage(Localizer["Message.ForgotUser"], MessageType.Info); + } + else + { + AddModuleMessage(Localizer["Message.UserDoesNotExist"], MessageType.Warning); + } + } + else + { + AddModuleMessage(Localizer["Message.ForgotPassword"], MessageType.Info); + } + + StateHasChanged(); + } + + private void Reset() + { + twofactor = false; + _username = ""; + _password = ""; + ClearModuleMessage(); + StateHasChanged(); + } private async Task KeyPressed(KeyboardEventArgs e) { diff --git a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor index 9f3d19ae..fc6d7891 100644 --- a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor +++ b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor @@ -41,6 +41,15 @@ else +
+ +
+ +
+
@@ -201,104 +210,119 @@ else } +

@code { - private string username = string.Empty; - private string password = string.Empty; - private string confirm = string.Empty; - private string email = string.Empty; - private string displayname = string.Empty; - private FileManager filemanager; - private int folderid = -1; - private int photofileid = -1; - private File photo = null; - private List profiles; - private Dictionary settings; - private string category = string.Empty; - private string filter = "to"; - private List notifications; + private string username = string.Empty; + private string password = string.Empty; + private string confirm = string.Empty; + private string twofactor = "False"; + private string email = string.Empty; + private string displayname = string.Empty; + private FileManager filemanager; + private int folderid = -1; + private int photofileid = -1; + private File photo = null; + private List profiles; + private Dictionary settings; + private string category = string.Empty; + private string filter = "to"; + private List notifications; - public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.View; + public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.View; - protected override async Task OnParametersSetAsync() - { - try - { - if (PageState.User != null) - { - username = PageState.User.Username; - email = PageState.User.Email; - displayname = PageState.User.DisplayName; + protected override async Task OnParametersSetAsync() + { + try + { + if (PageState.User != null) + { + username = PageState.User.Username; + twofactor = PageState.User.TwoFactorRequired.ToString(); + email = PageState.User.Email; + displayname = PageState.User.DisplayName; - // get user folder - var folder = await FolderService.GetFolderAsync(ModuleState.SiteId, PageState.User.FolderPath); - if (folder != null) - { - folderid = folder.FolderId; - } + // get user folder + var folder = await FolderService.GetFolderAsync(ModuleState.SiteId, PageState.User.FolderPath); + if (folder != null) + { + folderid = folder.FolderId; + } - if (PageState.User.PhotoFileId != null) - { - photofileid = PageState.User.PhotoFileId.Value; - photo = await FileService.GetFileAsync(photofileid); - } - else - { - photofileid = -1; - photo = null; - } + if (PageState.User.PhotoFileId != null) + { + photofileid = PageState.User.PhotoFileId.Value; + photo = await FileService.GetFileAsync(photofileid); + } + else + { + photofileid = -1; + photo = null; + } - profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId); - settings = await SettingService.GetUserSettingsAsync(PageState.User.UserId); + profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId); + settings = await SettingService.GetUserSettingsAsync(PageState.User.UserId); - await LoadNotificationsAsync(); - } - else - { - AddModuleMessage(Localizer["Message.User.NoLogIn"], MessageType.Warning); - } - } - catch (Exception ex) - { - await logger.LogError(ex, "Error Loading User Profile {Error}", ex.Message); - AddModuleMessage(Localizer["Error.Profile.Load"], MessageType.Error); - } - } + await LoadNotificationsAsync(); + } + else + { + AddModuleMessage(Localizer["Message.User.NoLogIn"], MessageType.Warning); + } + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Loading User Profile {Error}", ex.Message); + AddModuleMessage(Localizer["Error.Profile.Load"], MessageType.Error); + } + } - private async Task LoadNotificationsAsync() - { - notifications = await NotificationService.GetNotificationsAsync(PageState.Site.SiteId, filter, PageState.User.UserId); - notifications = notifications.Where(item => item.DeletedBy != PageState.User.Username).ToList(); - } + private async Task LoadNotificationsAsync() + { + notifications = await NotificationService.GetNotificationsAsync(PageState.Site.SiteId, filter, PageState.User.UserId); + notifications = notifications.Where(item => item.DeletedBy != PageState.User.Username).ToList(); + } - private string GetProfileValue(string SettingName, string DefaultValue) - => SettingService.GetSetting(settings, SettingName, DefaultValue); + private string GetProfileValue(string SettingName, string DefaultValue) + => SettingService.GetSetting(settings, SettingName, DefaultValue); - private async Task Save() - { - try - { - if (username != string.Empty && email != string.Empty && ValidateProfiles()) - { - if (password == confirm) - { - var user = PageState.User; - user.Username = username; - user.Password = password; - user.Email = email; - user.DisplayName = (displayname == string.Empty ? username : displayname); - user.PhotoFileId = filemanager.GetFileId(); - if (user.PhotoFileId == -1) - { - user.PhotoFileId = null; - } + private async Task Save() + { + try + { + if (username != string.Empty && email != string.Empty && ValidateProfiles()) + { + if (password == confirm) + { + var user = PageState.User; + user.Username = username; + user.Password = password; + user.TwoFactorRequired = bool.Parse(twofactor); + user.Email = email; + user.DisplayName = (displayname == string.Empty ? username : displayname); + user.PhotoFileId = filemanager.GetFileId(); + if (user.PhotoFileId == -1) + { + user.PhotoFileId = null; + } + if (user.PhotoFileId != null) + { + photofileid = user.PhotoFileId.Value; + photo = await FileService.GetFileAsync(photofileid); + } + else + { + photofileid = -1; + photo = null; + } - await UserService.UpdateUserAsync(user); - await SettingService.UpdateUserSettingsAsync(settings, PageState.User.UserId); - await logger.LogInformation("User Profile Saved"); + await UserService.UpdateUserAsync(user); + await SettingService.UpdateUserSettingsAsync(settings, PageState.User.UserId); + await logger.LogInformation("User Profile Saved"); - NavigationManager.NavigateTo(NavigateUrl()); - } + AddModuleMessage(Localizer["Success.Profile.Update"], MessageType.Success); + StateHasChanged(); + } else { AddModuleMessage(Localizer["Message.Password.Invalid"], MessageType.Warning); diff --git a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx index 36ae93c3..7e1ad433 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx @@ -117,9 +117,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - Remember Me? - Forgot Password @@ -130,10 +127,10 @@ User Account Could Not Be Verified. Please Contact Your Administrator For Further Instructions. - Login Failed. Please Remember That Passwords Are Case Sensitive And User Accounts Require Verification When They Are Initially Created So You May Wish To Check Your Email. + Login Failed. Please Remember That Passwords Are Case Sensitive. If You Have Attempted To Sign In 3 Times Unsuccessfully, Your Account Will Be Locked Out For 10 Minutes. Note That User Accounts Require Verification When They Are Initially Created So You May Wish To Check Your Email If You Are A New User. - Please Provide Your Username And Password + Please Provide All Required Fields You Are Already Signed In @@ -147,4 +144,43 @@ User Does Not Exist + + Please Enter The Secure Verification Code Which Was Sent To You By Email. + + + Verification Code + + + Verification Code: + + + Verification Failed. Please Ensure You Entered The Code Exactly In The Form Provided In Your Email. If You Wish To Request A New Verification Code Please Select The Cancel Option And Sign In Again. + + + A Secure Verification Code Has Been Sent To Your Email Address. Please Enter The Code That You Received. If You Do Not Receive The Code Within 10 Minutes Or You Have Lost Access To Your Email, Please Contact Your Administrator. + + + Please Enter The Password Related To Your Account. Remember That Passwords Are Case Sensitive. If Your Sign In Attempts Fail 3 Times In Succession, Your Account Will Be Locked Out For 10 Minutes. + + + Password + + + Password: + + + Specify If You Would Like To Be Signed Back In Automatically The Next Time You Visit This Site + + + Remember Me? + + + Please Enter The Username Related To Your Account + + + Username + + + Username: + \ No newline at end of file diff --git a/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx b/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx index 294cc895..22a36db3 100644 --- a/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx @@ -204,4 +204,10 @@ Username: + + Indicates if you are using two factor authentication + + + Two Factor Authentication? + \ No newline at end of file diff --git a/Oqtane.Client/Services/Interfaces/IUserService.cs b/Oqtane.Client/Services/Interfaces/IUserService.cs index 08289bea..28258449 100644 --- a/Oqtane.Client/Services/Interfaces/IUserService.cs +++ b/Oqtane.Client/Services/Interfaces/IUserService.cs @@ -88,5 +88,13 @@ namespace Oqtane.Services /// /// Task ResetPasswordAsync(User user, string token); + + /// + /// Verify the two factor verification code + /// + /// + /// + /// + Task VerifyTwoFactorAsync(User user, string token); } } diff --git a/Oqtane.Client/Services/UserService.cs b/Oqtane.Client/Services/UserService.cs index 6a981e54..77a83005 100644 --- a/Oqtane.Client/Services/UserService.cs +++ b/Oqtane.Client/Services/UserService.cs @@ -68,5 +68,10 @@ namespace Oqtane.Services { return await PostJsonAsync($"{Apiurl}/reset?token={token}", user); } + + public async Task VerifyTwoFactorAsync(User user, string token) + { + return await PostJsonAsync($"{Apiurl}/twofactor?token={token}", user); + } } } diff --git a/Oqtane.Server/Controllers/UserController.cs b/Oqtane.Server/Controllers/UserController.cs index 94e58881..d9920953 100644 --- a/Oqtane.Server/Controllers/UserController.cs +++ b/Oqtane.Server/Controllers/UserController.cs @@ -97,23 +97,30 @@ namespace Oqtane.Controllers private User Filter(User user) { - if (user != null && !User.IsInRole(RoleNames.Admin) && User.Identity.Name?.ToLower() != user.Username.ToLower()) + if (user != null) { - user.DisplayName = ""; - user.Email = ""; - user.PhotoFileId = null; - user.LastLoginOn = DateTime.MinValue; - user.LastIPAddress = ""; - user.Roles = ""; - user.CreatedBy = ""; - user.CreatedOn = DateTime.MinValue; - user.ModifiedBy = ""; - user.ModifiedOn = DateTime.MinValue; - user.DeletedBy = ""; - user.DeletedOn = DateTime.MinValue; - user.IsDeleted = false; user.Password = ""; user.IsAuthenticated = false; + user.TwoFactorCode = ""; + user.TwoFactorExpiry = null; + + if (!User.IsInRole(RoleNames.Admin) && User.Identity.Name?.ToLower() != user.Username.ToLower()) + { + user.DisplayName = ""; + user.Email = ""; + user.PhotoFileId = null; + user.LastLoginOn = DateTime.MinValue; + user.LastIPAddress = ""; + user.Roles = ""; + user.CreatedBy = ""; + user.CreatedOn = DateTime.MinValue; + user.ModifiedBy = ""; + user.ModifiedOn = DateTime.MinValue; + user.DeletedBy = ""; + user.DeletedOn = DateTime.MinValue; + user.IsDeleted = false; + user.TwoFactorRequired = false; + } } return user; } @@ -247,15 +254,14 @@ namespace Oqtane.Controllers { if (ModelState.IsValid && user.SiteId == _alias.SiteId && _users.GetUser(user.UserId, false) != null && (User.IsInRole(RoleNames.Admin) || User.Identity.Name == user.Username)) { - IdentityUser identityuser = await _identityUserManager.FindByNameAsync(user.Username); - if (identityuser != null) + if (user.Password != "") { - identityuser.TwoFactorEnabled = user.TwoFactorEnabled; - if (user.Password != "") + IdentityUser identityuser = await _identityUserManager.FindByNameAsync(user.Username); + if (identityuser != null) { identityuser.PasswordHash = _identityUserManager.PasswordHasher.HashPassword(identityuser, user.Password); + await _identityUserManager.UpdateAsync(identityuser); } - await _identityUserManager.UpdateAsync(identityuser); } user = _users.UpdateUser(user); _syncManager.AddSyncEvent(_alias.TenantId, EntityNames.User, user.UserId); @@ -333,7 +339,7 @@ namespace Oqtane.Controllers [HttpPost("login")] public async Task Login([FromBody] User user, bool setCookie, bool isPersistent) { - User loginUser = new User { Username = user.Username, IsAuthenticated = false }; + User loginUser = new User { SiteId = user.SiteId, Username = user.Username, IsAuthenticated = false }; if (ModelState.IsValid) { @@ -343,24 +349,44 @@ namespace Oqtane.Controllers var result = await _identitySignInManager.CheckPasswordSignInAsync(identityuser, user.Password, true); if (result.Succeeded) { - loginUser = _users.GetUser(identityuser.UserName); - if (loginUser != null) + user = _users.GetUser(user.Username); + if (user.TwoFactorRequired) { - if (identityuser.EmailConfirmed) + var token = await _identityUserManager.GenerateTwoFactorTokenAsync(identityuser, "Email"); + user.TwoFactorCode = token; + user.TwoFactorExpiry = DateTime.UtcNow.AddMinutes(10); + _users.UpdateUser(user); + + string body = "Dear " + user.DisplayName + ",\n\nYou requested a secure verification code to log in to your account. Please enter the secure verification code on the site:\n\n" + token + + "\n\nPlease note that the code is only valid for 10 minutes so if you are unable to take action within that time period, you should initiate a new login on the site." + + "\n\nThank You!"; + var notification = new Notification(loginUser.SiteId, user, "User Verification Code", body); + _notifications.AddNotification(notification); + + _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Verification Notification Sent For {Username}", user.Username); + loginUser.TwoFactorRequired = true; + } + else + { + loginUser = _users.GetUser(identityuser.UserName); + if (loginUser != null) { - loginUser.IsAuthenticated = true; - loginUser.LastLoginOn = DateTime.UtcNow; - loginUser.LastIPAddress = HttpContext.Connection.RemoteIpAddress.ToString(); - _users.UpdateUser(loginUser); - _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Login Successful {Username}", user.Username); - if (setCookie) + if (identityuser.EmailConfirmed) { - await _identitySignInManager.SignInAsync(identityuser, isPersistent); + loginUser.IsAuthenticated = true; + loginUser.LastLoginOn = DateTime.UtcNow; + loginUser.LastIPAddress = HttpContext.Connection.RemoteIpAddress.ToString(); + _users.UpdateUser(loginUser); + _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Login Successful {Username}", user.Username); + if (setCookie) + { + await _identitySignInManager.SignInAsync(identityuser, isPersistent); + } + } + else + { + _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Not Verified {Username}", user.Username); } - } - else - { - _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Not Verified {Username}", user.Username); } } } @@ -371,16 +397,16 @@ namespace Oqtane.Controllers user = _users.GetUser(user.Username); string token = await _identityUserManager.GeneratePasswordResetTokenAsync(identityuser); string url = HttpContext.Request.Scheme + "://" + _alias.Name + "/reset?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); - string body = "Dear " + user.DisplayName + ",\n\nYou attempted 3 times unsuccessfully to login to your account and it is now locked out. Please wait 10 minutes and then try again... or use the link below to reset your password:\n\n" + url + + string body = "Dear " + user.DisplayName + ",\n\nYou attempted 3 times unsuccessfully to log in to your account and it is now locked out. Please wait 10 minutes and then try again... or use the link below to reset your password:\n\n" + url + "\n\nPlease note that the link is only valid for 24 hours so if you are unable to take action within that time period, you should initiate another password reset on the site." + "\n\nThank You!"; - var notification = new Notification(user.SiteId, user, "User Password Lockout", body); + var notification = new Notification(loginUser.SiteId, user, "User Lockout", body); _notifications.AddNotification(notification); - _logger.Log(LogLevel.Information, this, LogFunction.Security, "Password Lockout Notification Sent For {Username}", user.Username); + _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Lockout Notification Sent For {Username}", user.Username); } else { - _logger.Log(LogLevel.Error, this, LogFunction.Security, "User Login Failed {Username}", user.Username); + _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Login Failed {Username}", user.Username); } } } @@ -485,6 +511,27 @@ namespace Oqtane.Controllers return user; } + // POST api//twofactor + [HttpPost("twofactor")] + public User TwoFactor([FromBody] User user, string token) + { + User loginUser = new User { SiteId = user.SiteId, Username = user.Username, IsAuthenticated = false }; + + if (ModelState.IsValid && !string.IsNullOrEmpty(token)) + { + user = _users.GetUser(user.Username); + if (user != null) + { + if (user.TwoFactorRequired && user.TwoFactorCode == token && DateTime.UtcNow < user.TwoFactorExpiry) + { + loginUser.IsAuthenticated = true; + } + } + } + + return loginUser; + } + // GET api//authenticate [HttpGet("authenticate")] public User Authenticate() diff --git a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs index 89f9895a..2d1c36a8 100644 --- a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs +++ b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs @@ -141,9 +141,9 @@ namespace Microsoft.Extensions.DependencyInjection options.Password.RequireLowercase = false; // Lockout settings - options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30); - options.Lockout.MaxFailedAccessAttempts = 10; - options.Lockout.AllowedForNewUsers = true; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10); + options.Lockout.MaxFailedAccessAttempts = 3; + options.Lockout.AllowedForNewUsers = false; // User settings options.User.RequireUniqueEmail = false; diff --git a/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs b/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs index 24ded671..5c1d0e28 100644 --- a/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs +++ b/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs @@ -52,64 +52,119 @@ namespace Oqtane.Migrations.EntityBuilders _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable); } + public void AddBooleanColumn(string name, bool nullable, bool defaultValue) + { + _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, defaultValue: defaultValue); + } + protected OperationBuilder AddBooleanColumn(ColumnsBuilder table, string name, bool nullable = false) { return table.Column(name: RewriteName(name), nullable: nullable); } + protected OperationBuilder AddBooleanColumn(ColumnsBuilder table, string name, bool nullable, bool defaultValue) + { + return table.Column(name: RewriteName(name), nullable: nullable, defaultValue: defaultValue); + } + public void AddDateTimeColumn(string name, bool nullable = false) { _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable); } + public void AddDateTimeColumn(string name, bool nullable, DateTime defaultValue) + { + _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, defaultValue: defaultValue); + } + protected OperationBuilder AddDateTimeColumn(ColumnsBuilder table, string name, bool nullable = false) { return table.Column(name: RewriteName(name), nullable: nullable); } + protected OperationBuilder AddDateTimeColumn(ColumnsBuilder table, string name, bool nullable, DateTime defaultValue) + { + return table.Column(name: RewriteName(name), nullable: nullable, defaultValue: defaultValue); + } + public void AddDateTimeOffsetColumn(string name, bool nullable = false) { _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable); } + public void AddDateTimeOffsetColumn(string name, bool nullable, DateTimeOffset defaultValue) + { + _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, defaultValue: defaultValue); + } + protected OperationBuilder AddDateTimeOffsetColumn(ColumnsBuilder table, string name, bool nullable = false) { return table.Column(name: RewriteName(name), nullable: nullable); } + protected OperationBuilder AddDateTimeOffsetColumn(ColumnsBuilder table, string name, bool nullable, DateTimeOffset defaultValue) + { + return table.Column(name: RewriteName(name), nullable: nullable, defaultValue: defaultValue); + } + public void AddIntegerColumn(string name, bool nullable = false) { _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable); } + public void AddIntegerColumn(string name, bool nullable, int defaultValue) + { + _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, defaultValue: defaultValue); + } + protected OperationBuilder AddIntegerColumn(ColumnsBuilder table, string name, bool nullable = false) { return table.Column(name: RewriteName(name), nullable: nullable); } + protected OperationBuilder AddIntegerColumn(ColumnsBuilder table, string name, bool nullable, int defaultValue) + { + return table.Column(name: RewriteName(name), nullable: nullable, defaultValue: defaultValue); + } + public void AddMaxStringColumn(string name, bool nullable = false, bool unicode = true) { _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, unicode: unicode); } + public void AddMaxStringColumn(string name, bool nullable, bool unicode, string defaultValue) + { + _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, unicode: unicode, defaultValue: defaultValue); + } + protected OperationBuilder AddMaxStringColumn(ColumnsBuilder table, string name, bool nullable = false, bool unicode = true) { return table.Column(name: RewriteName(name), nullable: nullable, unicode: unicode); } + protected OperationBuilder AddMaxStringColumn(ColumnsBuilder table, string name, bool nullable, bool unicode, string defaultValue) + { + return table.Column(name: RewriteName(name), nullable: nullable, unicode: unicode, defaultValue: defaultValue); + } + public void AddStringColumn(string name, int length, bool nullable = false, bool unicode = true) { _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), maxLength: length, nullable: nullable, unicode: unicode); } + public void AddStringColumn(string name, int length, bool nullable, bool unicode, string defaultValue) + { + _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), maxLength: length, nullable: nullable, unicode: unicode, defaultValue: defaultValue); + } + protected OperationBuilder AddStringColumn(ColumnsBuilder table, string name, int length, bool nullable = false, bool unicode = true) { return table.Column(name: RewriteName(name), maxLength: length, nullable: nullable, unicode: unicode); } - public void AlterStringColumn(string name, int length, bool nullable = false, bool unicode = true) + protected OperationBuilder AddStringColumn(ColumnsBuilder table, string name, int length, bool nullable, bool unicode, string defaultValue) { - ActiveDatabase.AlterStringColumn(_migrationBuilder, RewriteName(name), RewriteName(EntityTableName), length, nullable, unicode); + return table.Column(name: RewriteName(name), maxLength: length, nullable: nullable, unicode: unicode, defaultValue: defaultValue); } public void AddDecimalColumn(string name, int precision, int scale, bool nullable = false) @@ -117,11 +172,26 @@ namespace Oqtane.Migrations.EntityBuilders _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, precision: precision, scale: scale); } + public void AddDecimalColumn(string name, int precision, int scale, bool nullable, decimal defaultValue) + { + _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, precision: precision, scale: scale, defaultValue: defaultValue); + } + protected OperationBuilder AddDecimalColumn(ColumnsBuilder table, string name, int precision, int scale, bool nullable = false) { return table.Column(name: RewriteName(name), nullable: nullable, precision: precision, scale: scale); } + protected OperationBuilder AddDecimalColumn(ColumnsBuilder table, string name, int precision, int scale, bool nullable, decimal defaultValue) + { + return table.Column(name: RewriteName(name), nullable: nullable, precision: precision, scale: scale, defaultValue: defaultValue); + } + + public void AlterStringColumn(string name, int length, bool nullable = false, bool unicode = true) + { + ActiveDatabase.AlterStringColumn(_migrationBuilder, RewriteName(name), RewriteName(EntityTableName), length, nullable, unicode); + } + public void DropColumn(string name) { ActiveDatabase.DropColumn(_migrationBuilder, RewriteName(name), RewriteName(EntityTableName)); diff --git a/Oqtane.Server/Migrations/Tenant/03010002_AddUserTwoFactor.cs b/Oqtane.Server/Migrations/Tenant/03010002_AddUserTwoFactor.cs new file mode 100644 index 00000000..e38d84c4 --- /dev/null +++ b/Oqtane.Server/Migrations/Tenant/03010002_AddUserTwoFactor.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Oqtane.Databases.Interfaces; +using Oqtane.Migrations.EntityBuilders; +using Oqtane.Repository; + +namespace Oqtane.Migrations.Tenant +{ + [DbContext(typeof(TenantDBContext))] + [Migration("Tenant.03.01.00.02")] + public class AddUserTwoFactor : MultiDatabaseMigration + { + public AddUserTwoFactor(IDatabase database) : base(database) + { + } + + protected override void Up(MigrationBuilder migrationBuilder) + { + var userEntityBuilder = new UserEntityBuilder(migrationBuilder, ActiveDatabase); + userEntityBuilder.AddBooleanColumn("TwoFactorRequired", false, false); + userEntityBuilder.AddStringColumn("TwoFactorCode", 6, true); + userEntityBuilder.AddDateTimeColumn("TwoFactorExpiry", true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + var userEntityBuilder = new UserEntityBuilder(migrationBuilder, ActiveDatabase); + userEntityBuilder.DropColumn("TwoFactorRequired"); + userEntityBuilder.DropColumn("TwoFactorCode"); + userEntityBuilder.DropColumn("TwoFactorExpiry"); + } + } +} diff --git a/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css b/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css index abfbcc47..e25ff012 100644 --- a/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css +++ b/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css @@ -1,9 +1,5 @@ /* Login Module Custom Styles */ -.Oqtane-Modules-Admin-Login .username { - width: 200px; -} - -.Oqtane-Modules-Admin-Login .password { +.Oqtane-Modules-Admin-Login .input { width: 200px; } diff --git a/Oqtane.Shared/Models/User.cs b/Oqtane.Shared/Models/User.cs index b38d1db5..b227375b 100644 --- a/Oqtane.Shared/Models/User.cs +++ b/Oqtane.Shared/Models/User.cs @@ -43,6 +43,21 @@ namespace Oqtane.Models /// public string LastIPAddress { get; set; } + /// + /// Indicates if the user requires 2 factor authentication to sign in + /// + public bool TwoFactorRequired { get; set; } + + /// + /// Stores the 2 factor verification code + /// + public string TwoFactorCode { get; set; } + + /// + /// The expiry date/time for the 2 factor verification code + /// + public DateTime? TwoFactorExpiry { get; set; } + /// /// Reference to the this user belongs to. /// @@ -97,11 +112,5 @@ namespace Oqtane.Models { get => "Users\\" + UserId.ToString() + "\\"; } - - /// - /// Indicates if the user requires 2 factor authentication to sign in - /// - [NotMapped] - public bool TwoFactorEnabled { get; set; } } }