2 factor authentication and user account lockout completed

This commit is contained in:
Shaun Walker 2022-03-03 09:12:37 -05:00
parent 28629aa836
commit 1cdc80e09b
12 changed files with 561 additions and 302 deletions

View File

@ -7,10 +7,6 @@
@inject IStringLocalizer<Index> Localizer @inject IStringLocalizer<Index> Localizer
@inject IStringLocalizer<SharedResources> SharedLocalizer @inject IStringLocalizer<SharedResources> SharedLocalizer
@if (_message != string.Empty)
{
<ModuleMessage Message="@_message" Type="@_type" />
}
<AuthorizeView Roles="@RoleNames.Registered"> <AuthorizeView Roles="@RoleNames.Registered">
<Authorizing> <Authorizing>
<text>...</text> <text>...</text>
@ -19,179 +15,208 @@
<div>@Localizer["Info.SignedIn"]</div> <div>@Localizer["Info.SignedIn"]</div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
<form @ref="login" class="@(validated ? "was-validated" : "needs-validation")" novalidate> @if (!twofactor)
<div class="container Oqtane-Modules-Admin-Login" @onkeypress="@(e => KeyPressed(e))"> {
<div class="form-group"> <form @ref="login" class="@(validated ? "was-validated" : "needs-validation")" novalidate>
<label for="Username" class="control-label">@SharedLocalizer["Username"] </label> <div class="container Oqtane-Modules-Admin-Login" @onkeypress="@(e => KeyPressed(e))">
<input type="text" @ref="username" name="Username" class="form-control username" placeholder="Username" @bind="@_username" id="Username" required /> <div class="form-group">
</div> <Label Class="control-label" For="username" HelpText="Please enter your Username" ResourceKey="Username">Username:</Label>
<div class="form-group"> <input id="username" type="text" @ref="username" class="form-control input" placeholder="@Localizer["Username.Placeholder"]" @bind="@_username" required />
<label for="Password" class="control-label">@SharedLocalizer["Password"] </label> </div>
<input type="password" name="Password" class="form-control password" placeholder="Password" @bind="@_password" id="Password" required /> <div class="form-group mt-1">
</div> <Label Class="control-label" For="password" HelpText="Please enter your Password" ResourceKey="Password">Password:</Label>
<div class="form-group"> <input id="password" type="password" name="Password" class="form-control input" placeholder="@Localizer["Password.Placeholder"]" @bind="@_password" required />
<div class="form-check form-check-inline"> </div>
<label class="form-check-label" for="Remember">@Localizer["RememberMe"]</label>&nbsp; <div class="form-group mt-1">
<input type="checkbox" class="form-check-input" name="Remember" @bind="@_remember" id="Remember" /> <div class="form-check">
</div> <input id="remember" type="checkbox" class="form-check-input" @bind="@_remember" />
</div> <Label Class="control-label" For="remember" HelpText="Specify if you would like to be signed back in automatically the next time you visit this site" ResourceKey="Remember">Remember Me?</Label>
<button type="button" class="btn btn-primary" @onclick="Login">@SharedLocalizer["Login"]</button> </div>
<button type="button" class="btn btn-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button> </div>
<br /><br /> <button type="button" class="btn btn-primary" @onclick="Login">@SharedLocalizer["Login"]</button>
<button type="button" class="btn btn-secondary" @onclick="Forgot">@Localizer["ForgotPassword"]</button> <button type="button" class="btn btn-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button>
</div> <br /><br />
</form> <button type="button" class="btn btn-secondary" @onclick="Forgot">@Localizer["ForgotPassword"]</button>
</NotAuthorized> </div>
</form>
}
else
{
<form @ref="login" class="@(validated ? "was-validated" : "needs-validation")" novalidate>
<div class="container Oqtane-Modules-Admin-Login">
<div class="form-group">
<Label Class="control-label" For="code" HelpText="Please enter the secure verification code which was sent to you by email" ResourceKey="Code">Verification Code:</Label>
<input id="code" class="form-control input" @bind="@_code" placeholder="@Localizer["Code.Placeholder"]" maxlength="6" required />
</div>
<br />
<button type="button" class="btn btn-primary" @onclick="Login">@SharedLocalizer["Login"]</button>
<button type="button" class="btn btn-secondary" @onclick="Reset">@SharedLocalizer["Cancel"]</button>
</div>
</form>
}
</NotAuthorized>
</AuthorizeView> </AuthorizeView>
@code { @code {
private string _returnUrl = string.Empty; private ElementReference login;
private string _message = string.Empty; private bool validated = false;
private MessageType _type = MessageType.Info; private bool twofactor = false;
private string _username = string.Empty; private string _username = string.Empty;
private string _password = string.Empty; private ElementReference username;
private bool _remember = false; private string _password = string.Empty;
private bool validated = false; private bool _remember = false;
private string _code = string.Empty;
private ElementReference login; private string _returnUrl = string.Empty;
private ElementReference username;
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Anonymous; public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Anonymous;
public override List<Resource> Resources => new List<Resource>() public override List<Resource> Resources => new List<Resource>()
{ {
new Resource { ResourceType = ResourceType.Stylesheet, Url = ModulePath() + "Module.css" } new Resource { ResourceType = ResourceType.Stylesheet, Url = ModulePath() + "Module.css" }
}; };
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
if (PageState.QueryString.ContainsKey("returnurl")) if (PageState.QueryString.ContainsKey("returnurl"))
{ {
_returnUrl = PageState.QueryString["returnurl"]; _returnUrl = PageState.QueryString["returnurl"];
} }
if (PageState.QueryString.ContainsKey("name")) if (PageState.QueryString.ContainsKey("name"))
{ {
_username = PageState.QueryString["name"]; _username = PageState.QueryString["name"];
} }
if (PageState.QueryString.ContainsKey("token")) if (PageState.QueryString.ContainsKey("token"))
{ {
var user = new User(); var user = new User();
user.SiteId = PageState.Site.SiteId; user.SiteId = PageState.Site.SiteId;
user.Username = _username; user.Username = _username;
user = await UserService.VerifyEmailAsync(user, PageState.QueryString["token"]); user = await UserService.VerifyEmailAsync(user, PageState.QueryString["token"]);
if (user != null) if (user != null)
{ {
await logger.LogInformation(LogFunction.Security, "Email Verified For For Username {Username}", _username); await logger.LogInformation(LogFunction.Security, "Email Verified For For Username {Username}", _username);
_message = Localizer["Success.Account.Verified"]; AddModuleMessage(Localizer["Success.Account.Verified"], MessageType.Info);
} }
else else
{ {
await logger.LogError(LogFunction.Security, "Email Verification Failed For Username {Username}", _username); await logger.LogError(LogFunction.Security, "Email Verification Failed For Username {Username}", _username);
_message = Localizer["Message.Account.NotVerfied"]; AddModuleMessage(Localizer["Message.Account.NotVerfied"], MessageType.Warning);
_type = MessageType.Warning; }
} }
} }
}
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender && PageState.User == null)
{ {
if(PageState.User == null) await username.FocusAsync();
{ }
await username.FocusAsync(); }
}
}
}
private async Task Login() private async Task Login()
{ {
validated = true; validated = true;
var interop = new Interop(JSRuntime); var interop = new Interop(JSRuntime);
if (await interop.FormValid(login)) if (await interop.FormValid(login))
{ {
if (PageState.Runtime == Oqtane.Shared.Runtime.Server) var user = new User { SiteId = PageState.Site.SiteId, Username = _username, Password = _password};
{
var user = new User();
user.SiteId = PageState.Site.SiteId;
user.Username = _username;
user.Password = _password;
user = await UserService.LoginUserAsync(user, false, false);
if (user.IsAuthenticated) if (!twofactor)
{ {
await logger.LogInformation(LogFunction.Security, "Login Successful For Username {Username}", _username); user = await UserService.LoginUserAsync(user, false, false);
// 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 }; else
string url = Utilities.TenantUrl(PageState.Alias, "/pages/login/"); {
await interop.SubmitForm(url, fields); user = await UserService.VerifyTwoFactorAsync(user, _code);
} }
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);
}
}
private void Cancel() if (user.IsAuthenticated)
{ {
NavigationManager.NavigateTo(_returnUrl); await logger.LogInformation(LogFunction.Security, "Login Successful For Username {Username}", _username);
}
private async Task Forgot() if (PageState.Runtime == Oqtane.Shared.Runtime.Server)
{ {
if (_username != string.Empty) // 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 };
var user = await UserService.GetUserAsync(_username, PageState.Site.SiteId); string url = Utilities.TenantUrl(PageState.Alias, "/pages/login/");
if (user != null) await interop.SubmitForm(url, fields);
{ }
await UserService.ForgotPasswordAsync(user); else
await logger.LogInformation(LogFunction.Security, "Password Reset Notification Sent For Username {Username}", _username); {
_message = Localizer["Message.ForgotUser"]; var authstateprovider = (IdentityAuthenticationStateProvider)ServiceProvider.GetService(typeof(IdentityAuthenticationStateProvider));
} authstateprovider.NotifyAuthenticationChanged();
else NavigationManager.NavigateTo(NavigateUrl(_returnUrl, true));
{ }
_message = Localizer["Message.UserDoesNotExist"]; }
_type = MessageType.Warning; else
} {
} if (user.TwoFactorRequired)
else {
{ twofactor = true;
_message = Localizer["Message.ForgotPassword"]; 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) private async Task KeyPressed(KeyboardEventArgs e)
{ {

View File

@ -41,6 +41,15 @@ else
<input id="confirm" type="password" class="form-control" @bind="@confirm" autocomplete="new-password" /> <input id="confirm" type="password" class="form-control" @bind="@confirm" autocomplete="new-password" />
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="twofactor" HelpText="Indicates if you are using two factor authentication" ResourceKey="TwoFactor"></Label>
<div class="col-sm-9">
<select id="twofactor" class="form-select" @bind="@twofactor" required>
<option value="True">@SharedLocalizer["Yes"]</option>
<option value="False">@SharedLocalizer["No"]</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="email" HelpText="Your email address where you wish to receive notifications" ResourceKey="Email"></Label> <Label Class="col-sm-3" For="email" HelpText="Your email address where you wish to receive notifications" ResourceKey="Email"></Label>
<div class="col-sm-9"> <div class="col-sm-9">
@ -201,104 +210,119 @@ else
} }
</TabPanel> </TabPanel>
</TabStrip> </TabStrip>
<br /><br />
@code { @code {
private string username = string.Empty; private string username = string.Empty;
private string password = string.Empty; private string password = string.Empty;
private string confirm = string.Empty; private string confirm = string.Empty;
private string email = string.Empty; private string twofactor = "False";
private string displayname = string.Empty; private string email = string.Empty;
private FileManager filemanager; private string displayname = string.Empty;
private int folderid = -1; private FileManager filemanager;
private int photofileid = -1; private int folderid = -1;
private File photo = null; private int photofileid = -1;
private List<Profile> profiles; private File photo = null;
private Dictionary<string, string> settings; private List<Profile> profiles;
private string category = string.Empty; private Dictionary<string, string> settings;
private string filter = "to"; private string category = string.Empty;
private List<Notification> notifications; private string filter = "to";
private List<Notification> notifications;
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.View; public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.View;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
try try
{ {
if (PageState.User != null) if (PageState.User != null)
{ {
username = PageState.User.Username; username = PageState.User.Username;
email = PageState.User.Email; twofactor = PageState.User.TwoFactorRequired.ToString();
displayname = PageState.User.DisplayName; email = PageState.User.Email;
displayname = PageState.User.DisplayName;
// get user folder // get user folder
var folder = await FolderService.GetFolderAsync(ModuleState.SiteId, PageState.User.FolderPath); var folder = await FolderService.GetFolderAsync(ModuleState.SiteId, PageState.User.FolderPath);
if (folder != null) if (folder != null)
{ {
folderid = folder.FolderId; folderid = folder.FolderId;
} }
if (PageState.User.PhotoFileId != null) if (PageState.User.PhotoFileId != null)
{ {
photofileid = PageState.User.PhotoFileId.Value; photofileid = PageState.User.PhotoFileId.Value;
photo = await FileService.GetFileAsync(photofileid); photo = await FileService.GetFileAsync(photofileid);
} }
else else
{ {
photofileid = -1; photofileid = -1;
photo = null; photo = null;
} }
profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId); profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId);
settings = await SettingService.GetUserSettingsAsync(PageState.User.UserId); settings = await SettingService.GetUserSettingsAsync(PageState.User.UserId);
await LoadNotificationsAsync(); await LoadNotificationsAsync();
} }
else else
{ {
AddModuleMessage(Localizer["Message.User.NoLogIn"], MessageType.Warning); AddModuleMessage(Localizer["Message.User.NoLogIn"], MessageType.Warning);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
await logger.LogError(ex, "Error Loading User Profile {Error}", ex.Message); await logger.LogError(ex, "Error Loading User Profile {Error}", ex.Message);
AddModuleMessage(Localizer["Error.Profile.Load"], MessageType.Error); AddModuleMessage(Localizer["Error.Profile.Load"], MessageType.Error);
} }
} }
private async Task LoadNotificationsAsync() private async Task LoadNotificationsAsync()
{ {
notifications = await NotificationService.GetNotificationsAsync(PageState.Site.SiteId, filter, PageState.User.UserId); notifications = await NotificationService.GetNotificationsAsync(PageState.Site.SiteId, filter, PageState.User.UserId);
notifications = notifications.Where(item => item.DeletedBy != PageState.User.Username).ToList(); notifications = notifications.Where(item => item.DeletedBy != PageState.User.Username).ToList();
} }
private string GetProfileValue(string SettingName, string DefaultValue) private string GetProfileValue(string SettingName, string DefaultValue)
=> SettingService.GetSetting(settings, SettingName, DefaultValue); => SettingService.GetSetting(settings, SettingName, DefaultValue);
private async Task Save() private async Task Save()
{ {
try try
{ {
if (username != string.Empty && email != string.Empty && ValidateProfiles()) if (username != string.Empty && email != string.Empty && ValidateProfiles())
{ {
if (password == confirm) if (password == confirm)
{ {
var user = PageState.User; var user = PageState.User;
user.Username = username; user.Username = username;
user.Password = password; user.Password = password;
user.Email = email; user.TwoFactorRequired = bool.Parse(twofactor);
user.DisplayName = (displayname == string.Empty ? username : displayname); user.Email = email;
user.PhotoFileId = filemanager.GetFileId(); user.DisplayName = (displayname == string.Empty ? username : displayname);
if (user.PhotoFileId == -1) user.PhotoFileId = filemanager.GetFileId();
{ if (user.PhotoFileId == -1)
user.PhotoFileId = null; {
} 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 UserService.UpdateUserAsync(user);
await SettingService.UpdateUserSettingsAsync(settings, PageState.User.UserId); await SettingService.UpdateUserSettingsAsync(settings, PageState.User.UserId);
await logger.LogInformation("User Profile Saved"); await logger.LogInformation("User Profile Saved");
NavigationManager.NavigateTo(NavigateUrl()); AddModuleMessage(Localizer["Success.Profile.Update"], MessageType.Success);
} StateHasChanged();
}
else else
{ {
AddModuleMessage(Localizer["Message.Password.Invalid"], MessageType.Warning); AddModuleMessage(Localizer["Message.Password.Invalid"], MessageType.Warning);

View File

@ -117,9 +117,6 @@
<resheader name="writer"> <resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </resheader>
<data name="RememberMe" xml:space="preserve">
<value>Remember Me?</value>
</data>
<data name="ForgotPassword" xml:space="preserve"> <data name="ForgotPassword" xml:space="preserve">
<value>Forgot Password</value> <value>Forgot Password</value>
</data> </data>
@ -130,10 +127,10 @@
<value>User Account Could Not Be Verified. Please Contact Your Administrator For Further Instructions.</value> <value>User Account Could Not Be Verified. Please Contact Your Administrator For Further Instructions.</value>
</data> </data>
<data name="Error.Login.Fail" xml:space="preserve"> <data name="Error.Login.Fail" xml:space="preserve">
<value>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.</value> <value>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.</value>
</data> </data>
<data name="Message.Required.UserInfo" xml:space="preserve"> <data name="Message.Required.UserInfo" xml:space="preserve">
<value>Please Provide Your Username And Password</value> <value>Please Provide All Required Fields</value>
</data> </data>
<data name="Info.SignedIn" xml:space="preserve"> <data name="Info.SignedIn" xml:space="preserve">
<value>You Are Already Signed In</value> <value>You Are Already Signed In</value>
@ -147,4 +144,43 @@
<data name="Message.UserDoesNotExist" xml:space="preserve"> <data name="Message.UserDoesNotExist" xml:space="preserve">
<value>User Does Not Exist</value> <value>User Does Not Exist</value>
</data> </data>
<data name="Code.HelpText" xml:space="preserve">
<value>Please Enter The Secure Verification Code Which Was Sent To You By Email.</value>
</data>
<data name="Code.Placeholder" xml:space="preserve">
<value>Verification Code</value>
</data>
<data name="Code.Text" xml:space="preserve">
<value>Verification Code:</value>
</data>
<data name="Error.TwoFactor.Fail" xml:space="preserve">
<value>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. </value>
</data>
<data name="Message.TwoFactor" xml:space="preserve">
<value>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.</value>
</data>
<data name="Password.HelpText" xml:space="preserve">
<value>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.</value>
</data>
<data name="Password.Placeholder" xml:space="preserve">
<value>Password</value>
</data>
<data name="Password.Text" xml:space="preserve">
<value>Password:</value>
</data>
<data name="Remember.HelpText" xml:space="preserve">
<value>Specify If You Would Like To Be Signed Back In Automatically The Next Time You Visit This Site</value>
</data>
<data name="Remember.Text" xml:space="preserve">
<value>Remember Me?</value>
</data>
<data name="Username.HelpText" xml:space="preserve">
<value>Please Enter The Username Related To Your Account</value>
</data>
<data name="Username.Placeholder" xml:space="preserve">
<value>Username</value>
</data>
<data name="Username.Text" xml:space="preserve">
<value>Username:</value>
</data>
</root> </root>

View File

@ -204,4 +204,10 @@
<data name="Username.Text" xml:space="preserve"> <data name="Username.Text" xml:space="preserve">
<value>Username:</value> <value>Username:</value>
</data> </data>
<data name="TwoFactor.HelpText" xml:space="preserve">
<value>Indicates if you are using two factor authentication</value>
</data>
<data name="TwoFactor.Text" xml:space="preserve">
<value>Two Factor Authentication?</value>
</data>
</root> </root>

View File

@ -88,5 +88,13 @@ namespace Oqtane.Services
/// <param name="token"></param> /// <param name="token"></param>
/// <returns></returns> /// <returns></returns>
Task<User> ResetPasswordAsync(User user, string token); Task<User> ResetPasswordAsync(User user, string token);
/// <summary>
/// Verify the two factor verification code <see cref="User"/>
/// </summary>
/// <param name="user"></param>
/// <param name="token"></param>
/// <returns></returns>
Task<User> VerifyTwoFactorAsync(User user, string token);
} }
} }

View File

@ -68,5 +68,10 @@ namespace Oqtane.Services
{ {
return await PostJsonAsync<User>($"{Apiurl}/reset?token={token}", user); return await PostJsonAsync<User>($"{Apiurl}/reset?token={token}", user);
} }
public async Task<User> VerifyTwoFactorAsync(User user, string token)
{
return await PostJsonAsync<User>($"{Apiurl}/twofactor?token={token}", user);
}
} }
} }

View File

@ -97,23 +97,30 @@ namespace Oqtane.Controllers
private User Filter(User user) 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.Password = "";
user.IsAuthenticated = false; 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; 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)) 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 (user.Password != "")
if (identityuser != null)
{ {
identityuser.TwoFactorEnabled = user.TwoFactorEnabled; IdentityUser identityuser = await _identityUserManager.FindByNameAsync(user.Username);
if (user.Password != "") if (identityuser != null)
{ {
identityuser.PasswordHash = _identityUserManager.PasswordHasher.HashPassword(identityuser, user.Password); identityuser.PasswordHash = _identityUserManager.PasswordHasher.HashPassword(identityuser, user.Password);
await _identityUserManager.UpdateAsync(identityuser);
} }
await _identityUserManager.UpdateAsync(identityuser);
} }
user = _users.UpdateUser(user); user = _users.UpdateUser(user);
_syncManager.AddSyncEvent(_alias.TenantId, EntityNames.User, user.UserId); _syncManager.AddSyncEvent(_alias.TenantId, EntityNames.User, user.UserId);
@ -333,7 +339,7 @@ namespace Oqtane.Controllers
[HttpPost("login")] [HttpPost("login")]
public async Task<User> Login([FromBody] User user, bool setCookie, bool isPersistent) public async Task<User> 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) if (ModelState.IsValid)
{ {
@ -343,24 +349,44 @@ namespace Oqtane.Controllers
var result = await _identitySignInManager.CheckPasswordSignInAsync(identityuser, user.Password, true); var result = await _identitySignInManager.CheckPasswordSignInAsync(identityuser, user.Password, true);
if (result.Succeeded) if (result.Succeeded)
{ {
loginUser = _users.GetUser(identityuser.UserName); user = _users.GetUser(user.Username);
if (loginUser != null) 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; if (identityuser.EmailConfirmed)
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); 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); user = _users.GetUser(user.Username);
string token = await _identityUserManager.GeneratePasswordResetTokenAsync(identityuser); string token = await _identityUserManager.GeneratePasswordResetTokenAsync(identityuser);
string url = HttpContext.Request.Scheme + "://" + _alias.Name + "/reset?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); 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\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!"; "\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); _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 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; return user;
} }
// POST api/<controller>/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/<controller>/authenticate // GET api/<controller>/authenticate
[HttpGet("authenticate")] [HttpGet("authenticate")]
public User Authenticate() public User Authenticate()

View File

@ -141,9 +141,9 @@ namespace Microsoft.Extensions.DependencyInjection
options.Password.RequireLowercase = false; options.Password.RequireLowercase = false;
// Lockout settings // Lockout settings
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30); options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10);
options.Lockout.MaxFailedAccessAttempts = 10; options.Lockout.MaxFailedAccessAttempts = 3;
options.Lockout.AllowedForNewUsers = true; options.Lockout.AllowedForNewUsers = false;
// User settings // User settings
options.User.RequireUniqueEmail = false; options.User.RequireUniqueEmail = false;

View File

@ -52,64 +52,119 @@ namespace Oqtane.Migrations.EntityBuilders
_migrationBuilder.AddColumn<bool>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable); _migrationBuilder.AddColumn<bool>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable);
} }
public void AddBooleanColumn(string name, bool nullable, bool defaultValue)
{
_migrationBuilder.AddColumn<bool>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, defaultValue: defaultValue);
}
protected OperationBuilder<AddColumnOperation> AddBooleanColumn(ColumnsBuilder table, string name, bool nullable = false) protected OperationBuilder<AddColumnOperation> AddBooleanColumn(ColumnsBuilder table, string name, bool nullable = false)
{ {
return table.Column<bool>(name: RewriteName(name), nullable: nullable); return table.Column<bool>(name: RewriteName(name), nullable: nullable);
} }
protected OperationBuilder<AddColumnOperation> AddBooleanColumn(ColumnsBuilder table, string name, bool nullable, bool defaultValue)
{
return table.Column<bool>(name: RewriteName(name), nullable: nullable, defaultValue: defaultValue);
}
public void AddDateTimeColumn(string name, bool nullable = false) public void AddDateTimeColumn(string name, bool nullable = false)
{ {
_migrationBuilder.AddColumn<DateTime>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable); _migrationBuilder.AddColumn<DateTime>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable);
} }
public void AddDateTimeColumn(string name, bool nullable, DateTime defaultValue)
{
_migrationBuilder.AddColumn<DateTime>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, defaultValue: defaultValue);
}
protected OperationBuilder<AddColumnOperation> AddDateTimeColumn(ColumnsBuilder table, string name, bool nullable = false) protected OperationBuilder<AddColumnOperation> AddDateTimeColumn(ColumnsBuilder table, string name, bool nullable = false)
{ {
return table.Column<DateTime>(name: RewriteName(name), nullable: nullable); return table.Column<DateTime>(name: RewriteName(name), nullable: nullable);
} }
protected OperationBuilder<AddColumnOperation> AddDateTimeColumn(ColumnsBuilder table, string name, bool nullable, DateTime defaultValue)
{
return table.Column<DateTime>(name: RewriteName(name), nullable: nullable, defaultValue: defaultValue);
}
public void AddDateTimeOffsetColumn(string name, bool nullable = false) public void AddDateTimeOffsetColumn(string name, bool nullable = false)
{ {
_migrationBuilder.AddColumn<DateTimeOffset>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable); _migrationBuilder.AddColumn<DateTimeOffset>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable);
} }
public void AddDateTimeOffsetColumn(string name, bool nullable, DateTimeOffset defaultValue)
{
_migrationBuilder.AddColumn<DateTimeOffset>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, defaultValue: defaultValue);
}
protected OperationBuilder<AddColumnOperation> AddDateTimeOffsetColumn(ColumnsBuilder table, string name, bool nullable = false) protected OperationBuilder<AddColumnOperation> AddDateTimeOffsetColumn(ColumnsBuilder table, string name, bool nullable = false)
{ {
return table.Column<DateTimeOffset>(name: RewriteName(name), nullable: nullable); return table.Column<DateTimeOffset>(name: RewriteName(name), nullable: nullable);
} }
protected OperationBuilder<AddColumnOperation> AddDateTimeOffsetColumn(ColumnsBuilder table, string name, bool nullable, DateTimeOffset defaultValue)
{
return table.Column<DateTimeOffset>(name: RewriteName(name), nullable: nullable, defaultValue: defaultValue);
}
public void AddIntegerColumn(string name, bool nullable = false) public void AddIntegerColumn(string name, bool nullable = false)
{ {
_migrationBuilder.AddColumn<int>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable); _migrationBuilder.AddColumn<int>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable);
} }
public void AddIntegerColumn(string name, bool nullable, int defaultValue)
{
_migrationBuilder.AddColumn<int>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, defaultValue: defaultValue);
}
protected OperationBuilder<AddColumnOperation> AddIntegerColumn(ColumnsBuilder table, string name, bool nullable = false) protected OperationBuilder<AddColumnOperation> AddIntegerColumn(ColumnsBuilder table, string name, bool nullable = false)
{ {
return table.Column<int>(name: RewriteName(name), nullable: nullable); return table.Column<int>(name: RewriteName(name), nullable: nullable);
} }
protected OperationBuilder<AddColumnOperation> AddIntegerColumn(ColumnsBuilder table, string name, bool nullable, int defaultValue)
{
return table.Column<int>(name: RewriteName(name), nullable: nullable, defaultValue: defaultValue);
}
public void AddMaxStringColumn(string name, bool nullable = false, bool unicode = true) public void AddMaxStringColumn(string name, bool nullable = false, bool unicode = true)
{ {
_migrationBuilder.AddColumn<string>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, unicode: unicode); _migrationBuilder.AddColumn<string>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, unicode: unicode);
} }
public void AddMaxStringColumn(string name, bool nullable, bool unicode, string defaultValue)
{
_migrationBuilder.AddColumn<string>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, unicode: unicode, defaultValue: defaultValue);
}
protected OperationBuilder<AddColumnOperation> AddMaxStringColumn(ColumnsBuilder table, string name, bool nullable = false, bool unicode = true) protected OperationBuilder<AddColumnOperation> AddMaxStringColumn(ColumnsBuilder table, string name, bool nullable = false, bool unicode = true)
{ {
return table.Column<string>(name: RewriteName(name), nullable: nullable, unicode: unicode); return table.Column<string>(name: RewriteName(name), nullable: nullable, unicode: unicode);
} }
protected OperationBuilder<AddColumnOperation> AddMaxStringColumn(ColumnsBuilder table, string name, bool nullable, bool unicode, string defaultValue)
{
return table.Column<string>(name: RewriteName(name), nullable: nullable, unicode: unicode, defaultValue: defaultValue);
}
public void AddStringColumn(string name, int length, bool nullable = false, bool unicode = true) public void AddStringColumn(string name, int length, bool nullable = false, bool unicode = true)
{ {
_migrationBuilder.AddColumn<string>(RewriteName(name), RewriteName(EntityTableName), maxLength: length, nullable: nullable, unicode: unicode); _migrationBuilder.AddColumn<string>(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<string>(RewriteName(name), RewriteName(EntityTableName), maxLength: length, nullable: nullable, unicode: unicode, defaultValue: defaultValue);
}
protected OperationBuilder<AddColumnOperation> AddStringColumn(ColumnsBuilder table, string name, int length, bool nullable = false, bool unicode = true) protected OperationBuilder<AddColumnOperation> AddStringColumn(ColumnsBuilder table, string name, int length, bool nullable = false, bool unicode = true)
{ {
return table.Column<string>(name: RewriteName(name), maxLength: length, nullable: nullable, unicode: unicode); return table.Column<string>(name: RewriteName(name), maxLength: length, nullable: nullable, unicode: unicode);
} }
public void AlterStringColumn(string name, int length, bool nullable = false, bool unicode = true) protected OperationBuilder<AddColumnOperation> 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<string>(name: RewriteName(name), maxLength: length, nullable: nullable, unicode: unicode, defaultValue: defaultValue);
} }
public void AddDecimalColumn(string name, int precision, int scale, bool nullable = false) public void AddDecimalColumn(string name, int precision, int scale, bool nullable = false)
@ -117,11 +172,26 @@ namespace Oqtane.Migrations.EntityBuilders
_migrationBuilder.AddColumn<decimal>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, precision: precision, scale: scale); _migrationBuilder.AddColumn<decimal>(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<decimal>(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, precision: precision, scale: scale, defaultValue: defaultValue);
}
protected OperationBuilder<AddColumnOperation> AddDecimalColumn(ColumnsBuilder table, string name, int precision, int scale, bool nullable = false) protected OperationBuilder<AddColumnOperation> AddDecimalColumn(ColumnsBuilder table, string name, int precision, int scale, bool nullable = false)
{ {
return table.Column<decimal>(name: RewriteName(name), nullable: nullable, precision: precision, scale: scale); return table.Column<decimal>(name: RewriteName(name), nullable: nullable, precision: precision, scale: scale);
} }
protected OperationBuilder<AddColumnOperation> AddDecimalColumn(ColumnsBuilder table, string name, int precision, int scale, bool nullable, decimal defaultValue)
{
return table.Column<decimal>(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) public void DropColumn(string name)
{ {
ActiveDatabase.DropColumn(_migrationBuilder, RewriteName(name), RewriteName(EntityTableName)); ActiveDatabase.DropColumn(_migrationBuilder, RewriteName(name), RewriteName(EntityTableName));

View File

@ -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");
}
}
}

View File

@ -1,9 +1,5 @@
/* Login Module Custom Styles */ /* Login Module Custom Styles */
.Oqtane-Modules-Admin-Login .username { .Oqtane-Modules-Admin-Login .input {
width: 200px;
}
.Oqtane-Modules-Admin-Login .password {
width: 200px; width: 200px;
} }

View File

@ -43,6 +43,21 @@ namespace Oqtane.Models
/// </summary> /// </summary>
public string LastIPAddress { get; set; } public string LastIPAddress { get; set; }
/// <summary>
/// Indicates if the user requires 2 factor authentication to sign in
/// </summary>
public bool TwoFactorRequired { get; set; }
/// <summary>
/// Stores the 2 factor verification code
/// </summary>
public string TwoFactorCode { get; set; }
/// <summary>
/// The expiry date/time for the 2 factor verification code
/// </summary>
public DateTime? TwoFactorExpiry { get; set; }
/// <summary> /// <summary>
/// Reference to the <see cref="Site"/> this user belongs to. /// Reference to the <see cref="Site"/> this user belongs to.
/// </summary> /// </summary>
@ -97,11 +112,5 @@ namespace Oqtane.Models
{ {
get => "Users\\" + UserId.ToString() + "\\"; get => "Users\\" + UserId.ToString() + "\\";
} }
/// <summary>
/// Indicates if the user requires 2 factor authentication to sign in
/// </summary>
[NotMapped]
public bool TwoFactorEnabled { get; set; }
} }
} }