fix #5349 - send verification email if unverified user attempts to login, add ability to enable/disable email verification per site

This commit is contained in:
sbwalker
2025-07-29 16:20:07 -04:00
parent 658059806b
commit f4cea3fe03
13 changed files with 101 additions and 45 deletions

View File

@ -21,9 +21,7 @@ else
@if (_allowexternallogin) @if (_allowexternallogin)
{ {
<button type="button" class="btn btn-primary" @onclick="ExternalLogin">@Localizer["Use"] @PageState.Site.Settings["ExternalLogin:ProviderName"]</button> <button type="button" class="btn btn-primary" @onclick="ExternalLogin">@Localizer["Use"] @PageState.Site.Settings["ExternalLogin:ProviderName"]</button>
<br /> <br /><br />
<br />
} }
@if (_allowsitelogin) @if (_allowsitelogin)
{ {
@ -49,15 +47,11 @@ else
</div> </div>
<button type="button" class="btn btn-primary" @onclick="Login">@SharedLocalizer["Login"]</button> <button type="button" class="btn btn-primary" @onclick="Login">@SharedLocalizer["Login"]</button>
<button type="button" class="btn btn-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button> <button type="button" class="btn btn-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button>
<br /> <br /><br />
<br />
<button type="button" class="btn btn-secondary" @onclick="Forgot">@Localizer["ForgotPassword"]</button> <button type="button" class="btn btn-secondary" @onclick="Forgot">@Localizer["ForgotPassword"]</button>
@if (PageState.Site.AllowRegistration) @if (PageState.Site.AllowRegistration)
{ {
<br /> <br /><br />
<br />
<NavLink href="@NavigateUrl("register")">@Localizer["Register"]</NavLink> <NavLink href="@NavigateUrl("register")">@Localizer["Register"]</NavLink>
} }
} }

View File

@ -28,6 +28,15 @@
<input id="email" class="form-control" @bind="@_email" /> <input id="email" class="form-control" @bind="@_email" />
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="confirmed" HelpText="Indicates if the user's email is verified" ResourceKey="Confirmed">Verified?</Label>
<div class="col-sm-9">
<select id="confirmed" class="form-select" @bind="@_confirmed">
<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="displayname" HelpText="The full name of the user" ResourceKey="DisplayName"></Label> <Label Class="col-sm-3" For="displayname" HelpText="The full name of the user" ResourceKey="DisplayName"></Label>
<div class="col-sm-9"> <div class="col-sm-9">
@ -120,6 +129,7 @@
private bool _initialized = false; private bool _initialized = false;
private string _username = string.Empty; private string _username = string.Empty;
private string _email = string.Empty; private string _email = string.Empty;
private string _confirmed = "True";
private string _displayname = string.Empty; private string _displayname = string.Empty;
private string _timezoneid = string.Empty; private string _timezoneid = string.Empty;
private string _notify = "True"; private string _notify = "True";
@ -169,6 +179,7 @@
user.Username = _username; user.Username = _username;
user.Password = ""; // will be auto generated user.Password = ""; // will be auto generated
user.Email = _email; user.Email = _email;
user.EmailConfirmed = bool.Parse(_confirmed);
user.DisplayName = string.IsNullOrWhiteSpace(_displayname) ? _username : _displayname; user.DisplayName = string.IsNullOrWhiteSpace(_displayname) ? _username : _displayname;
user.TimeZoneId = _timezoneid; user.TimeZoneId = _timezoneid;
user.PhotoFileId = null; user.PhotoFileId = null;

View File

@ -48,7 +48,7 @@
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="confirmed" HelpText="Indicates if the user's email is verified" ResourceKey="Confirmed">Confirmed?</Label> <Label Class="col-sm-3" For="confirmed" HelpText="Indicates if the user's email is verified" ResourceKey="Confirmed">Verified?</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<select id="confirmed" class="form-select" @bind="@_confirmed"> <select id="confirmed" class="form-select" @bind="@_confirmed">
<option value="True">@SharedLocalizer["Yes"]</option> <option value="True">@SharedLocalizer["Yes"]</option>

View File

@ -74,10 +74,19 @@ else
<input id="profileurl" class="form-control" @bind="@_profileurl" /> <input id="profileurl" class="form-control" @bind="@_profileurl" />
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="requirevalidemail" HelpText="Do you want to require registered users to validate their email address before they are allowed to log in?" ResourceKey="RequireValidEmail">Require Valid Email?</Label>
<div class="col-sm-9">
<select id="requirevalidemail" class="form-select" @bind="@_requirevalidemail">
<option value="true">@SharedLocalizer["Yes"]</option>
<option value="false">@SharedLocalizer["No"]</option>
</select>
</div>
</div>
@if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) @if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host))
{ {
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="twofactor" HelpText="Do you want users to use two factor authentication? Note that you should use the Disabled option until you have successfully verified that the Notification Job in Scheduled Jobs is enabled and your SMTP options in Site Settings are configured or else you will lock yourself out." ResourceKey="TwoFactor">Two Factor?</Label> <Label Class="col-sm-3" For="twofactor" HelpText="Do you want users to use two factor authentication? Note that you should use the Disabled option until you have successfully verified that the Notification Job in Scheduled Jobs is enabled and your SMTP options in Site Settings are configured or else you will lock yourself out." ResourceKey="TwoFactor">Two Factor Authentication?</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<select id="twofactor" class="form-select" @bind="@_twofactor"> <select id="twofactor" class="form-select" @bind="@_twofactor">
<option value="false">@Localizer["Disabled"]</option> <option value="false">@Localizer["Disabled"]</option>
@ -490,6 +499,7 @@ else
private string _allowregistration; private string _allowregistration;
private string _registerurl; private string _registerurl;
private string _profileurl; private string _profileurl;
private string _requirevalidemail;
private string _twofactor; private string _twofactor;
private string _cookiename; private string _cookiename;
private string _cookieexpiration; private string _cookieexpiration;
@ -560,6 +570,7 @@ else
_allowregistration = PageState.Site.AllowRegistration.ToString().ToLower(); _allowregistration = PageState.Site.AllowRegistration.ToString().ToLower();
_registerurl = SettingService.GetSetting(settings, "LoginOptions:RegisterUrl", ""); _registerurl = SettingService.GetSetting(settings, "LoginOptions:RegisterUrl", "");
_profileurl = SettingService.GetSetting(settings, "LoginOptions:ProfileUrl", ""); _profileurl = SettingService.GetSetting(settings, "LoginOptions:ProfileUrl", "");
_requirevalidemail = SettingService.GetSetting(settings, "LoginOptions:RequireValidEmail", "true");
if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host))
{ {
@ -685,6 +696,7 @@ else
{ {
settings = SettingService.SetSetting(settings, "LoginOptions:RegisterUrl", _registerurl, false); settings = SettingService.SetSetting(settings, "LoginOptions:RegisterUrl", _registerurl, false);
settings = SettingService.SetSetting(settings, "LoginOptions:ProfileUrl", _profileurl, false); settings = SettingService.SetSetting(settings, "LoginOptions:ProfileUrl", _profileurl, false);
settings = SettingService.SetSetting(settings, "LoginOptions:RequireValidEmail", _requirevalidemail, false);
settings = SettingService.SetSetting(settings, "LoginOptions:TwoFactor", _twofactor, false); settings = SettingService.SetSetting(settings, "LoginOptions:TwoFactor", _twofactor, false);
settings = SettingService.SetSetting(settings, "LoginOptions:CookieName", _cookiename, true); settings = SettingService.SetSetting(settings, "LoginOptions:CookieName", _cookiename, true);
settings = SettingService.SetSetting(settings, "LoginOptions:CookieExpiration", _cookieexpiration, true); settings = SettingService.SetSetting(settings, "LoginOptions:CookieExpiration", _cookieexpiration, true);

View File

@ -133,7 +133,7 @@
<value>External Login Could Not Be Linked. Please Contact Your Administrator For Further Instructions.</value> <value>External Login Could Not Be Linked. 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. If You Have Attempted To Sign In Multiple Times Unsuccessfully, Your Account Will Be Locked Out For A Period Of Time. Note That User Accounts Often Require Email Address Verification So You May Wish To Check Your Email For A Notification.</value> <value>Login Failed. Please Remember That Passwords Are Case Sensitive. If You Have Attempted To Sign In Multiple Times Unsuccessfully, Your Account Will Be Locked Out For A Period Of Time. Note That New User Accounts Often Require Email Address Verification So You May Wish To Check Your Email For A Notification Containing Further Instructions.</value>
</data> </data>
<data name="Message.Required.UserInfo" xml:space="preserve"> <data name="Message.Required.UserInfo" xml:space="preserve">
<value>Please Provide All Required Fields</value> <value>Please Provide All Required Fields</value>

View File

@ -162,4 +162,10 @@
<data name="TimeZone.HelpText" xml:space="preserve"> <data name="TimeZone.HelpText" xml:space="preserve">
<value>The user's time zone</value> <value>The user's time zone</value>
</data> </data>
<data name="Confirmed.Text" xml:space="preserve">
<value>Verified?</value>
</data>
<data name="Confirmed.HelpText" xml:space="preserve">
<value>Indicates if the user's email is verified</value>
</data>
</root> </root>

View File

@ -217,7 +217,7 @@
<value>The user's time zone</value> <value>The user's time zone</value>
</data> </data>
<data name="Confirmed.Text" xml:space="preserve"> <data name="Confirmed.Text" xml:space="preserve">
<value>Confirmed?</value> <value>Verified?</value>
</data> </data>
<data name="Confirmed.HelpText" xml:space="preserve"> <data name="Confirmed.HelpText" xml:space="preserve">
<value>Indicates if the user's email is verified</value> <value>Indicates if the user's email is verified</value>

View File

@ -370,7 +370,13 @@
<value>Do you want users to use two factor authentication? Note that you should use the Disabled option until you have successfully verified that the Notification Job in Scheduled Jobs is enabled and your SMTP options in Site Settings are configured or else you will lock yourself out.</value> <value>Do you want users to use two factor authentication? Note that you should use the Disabled option until you have successfully verified that the Notification Job in Scheduled Jobs is enabled and your SMTP options in Site Settings are configured or else you will lock yourself out.</value>
</data> </data>
<data name="TwoFactor.Text" xml:space="preserve"> <data name="TwoFactor.Text" xml:space="preserve">
<value>Two Factor?</value> <value>Two Factor Authentication?</value>
</data>
<data name="RequireValidEmail.HelpText" xml:space="preserve">
<value>Do you want to require registered users to validate their email address before they are allowed to log in?</value>
</data>
<data name="RequireValidEmail.Text" xml:space="preserve">
<value>Require Valid Email?</value>
</data> </data>
<data name="Disabled" xml:space="preserve"> <data name="Disabled" xml:space="preserve">
<value>Disabled</value> <value>Disabled</value>

View File

@ -165,14 +165,13 @@ namespace Oqtane.Controllers
bool allowregistration; bool allowregistration;
if (_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin)) if (_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin))
{ {
user.EmailConfirmed = true; user.IsAuthenticated = true; // admins can add any existing user to a site
user.IsAuthenticated = true;
allowregistration = true; allowregistration = true;
} }
else else
{ {
user.EmailConfirmed = false; user.EmailConfirmed = false; // standard users cannot specify that their email is verified
user.IsAuthenticated = false; user.IsAuthenticated = false; // existing users can only be added to a site if they provide a valid username and password
allowregistration = _sites.GetSite(user.SiteId).AllowRegistration; allowregistration = _sites.GetSite(user.SiteId).AllowRegistration;
} }

View File

@ -228,11 +228,12 @@ namespace Microsoft.Extensions.DependencyInjection
options.Lockout.AllowedForNewUsers = false; options.Lockout.AllowedForNewUsers = false;
// SignIn settings // SignIn settings
options.SignIn.RequireConfirmedEmail = true; options.SignIn.RequireConfirmedEmail = false;
options.SignIn.RequireConfirmedAccount = false;
options.SignIn.RequireConfirmedPhoneNumber = false; options.SignIn.RequireConfirmedPhoneNumber = false;
// User settings // User settings
options.User.RequireUniqueEmail = false; // changing to true will cause issues for legacy data options.User.RequireUniqueEmail = false;
options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+"; options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
}); });

View File

@ -180,7 +180,7 @@ namespace Oqtane.Managers
if (User != null) if (User != null)
{ {
string siteName = _sites.GetSite(user.SiteId).Name; string siteName = _sites.GetSite(user.SiteId).Name;
if (!user.EmailConfirmed) if (!user.EmailConfirmed && bool.Parse(_settings.GetSettingValue(EntityNames.Site, alias.SiteId, "LoginOptions:RequireValidEmail", "true")))
{ {
string token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser); string token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
string url = alias.Protocol + alias.Name + "/login?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); string url = alias.Protocol + alias.Name + "/login?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token);
@ -252,29 +252,32 @@ namespace Oqtane.Managers
await _identityUserManager.UpdateAsync(identityuser); // security stamp not updated await _identityUserManager.UpdateAsync(identityuser); // security stamp not updated
} }
if (user.EmailConfirmed) if (bool.Parse(_settings.GetSettingValue(EntityNames.Site, alias.SiteId, "LoginOptions:RequireValidEmail", "true")))
{ {
if (!identityuser.EmailConfirmed) if (user.EmailConfirmed)
{ {
var emailConfirmationToken = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser); if (!identityuser.EmailConfirmed)
await _identityUserManager.ConfirmEmailAsync(identityuser, emailConfirmationToken); {
var emailConfirmationToken = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
await _identityUserManager.ConfirmEmailAsync(identityuser, emailConfirmationToken);
string body = "Dear " + user.DisplayName + ",\n\nThe Email Address For Your User Account Has Been Verified. You Can Now Login With Your Username And Password."; string body = "Dear " + user.DisplayName + ",\n\nThe Email Address For Your User Account Has Been Verified. You Can Now Login With Your Username And Password.";
var notification = new Notification(user.SiteId, user, "User Account Verification", body);
_notifications.AddNotification(notification);
}
}
else
{
identityuser.EmailConfirmed = false;
await _identityUserManager.UpdateAsync(identityuser); // security stamp not updated
string token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
string url = alias.Protocol + alias.Name + "/login?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token);
string body = "Dear " + user.DisplayName + ",\n\nIn Order To Verify The Email Address Associated To Your User Account Please Click The Link Displayed Below:\n\n" + url + "\n\nThank You!";
var notification = new Notification(user.SiteId, user, "User Account Verification", body); var notification = new Notification(user.SiteId, user, "User Account Verification", body);
_notifications.AddNotification(notification); _notifications.AddNotification(notification);
} }
} }
else
{
identityuser.EmailConfirmed = false;
await _identityUserManager.UpdateAsync(identityuser); // security stamp not updated
string token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
string url = alias.Protocol + alias.Name + "/login?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token);
string body = "Dear " + user.DisplayName + ",\n\nIn Order To Verify The Email Address Associated To Your User Account Please Click The Link Displayed Below:\n\n" + url + "\n\nThank You!";
var notification = new Notification(user.SiteId, user, "User Account Verification", body);
_notifications.AddNotification(notification);
}
user = _users.UpdateUser(user); user = _users.UpdateUser(user);
_syncManager.AddSyncEvent(_tenantManager.GetAlias(), EntityNames.User, user.UserId, SyncEventActions.Update); _syncManager.AddSyncEvent(_tenantManager.GetAlias(), EntityNames.User, user.UserId, SyncEventActions.Update);
@ -354,15 +357,14 @@ namespace Oqtane.Managers
if (!user.IsDeleted) if (!user.IsDeleted)
{ {
var alias = _tenantManager.GetAlias(); var alias = _tenantManager.GetAlias();
var twoFactorSetting = _settings.GetSetting(EntityNames.Site, alias.SiteId, "LoginOptions:TwoFactor")?.SettingValue ?? "false"; string siteName = _sites.GetSite(alias.SiteId).Name;
var twoFactorRequired = twoFactorSetting == "required" || user.TwoFactorRequired; var twoFactorRequired = _settings.GetSettingValue(EntityNames.Site, alias.SiteId, "LoginOptions:TwoFactor", "false") == "required" || user.TwoFactorRequired;
if (twoFactorRequired) if (twoFactorRequired)
{ {
var token = await _identityUserManager.GenerateTwoFactorTokenAsync(identityuser, "Email"); var token = await _identityUserManager.GenerateTwoFactorTokenAsync(identityuser, "Email");
user.TwoFactorCode = token; user.TwoFactorCode = token;
user.TwoFactorExpiry = DateTime.UtcNow.AddMinutes(10); user.TwoFactorExpiry = DateTime.UtcNow.AddMinutes(10);
_users.UpdateUser(user); _users.UpdateUser(user);
string siteName = _sites.GetSite(alias.SiteId).Name;
string subject = _localizer["TwoFactorEmailSubject"]; string subject = _localizer["TwoFactorEmailSubject"];
subject = subject.Replace("[SiteName]", siteName); subject = subject.Replace("[SiteName]", siteName);
string body = _localizer["TwoFactorEmailBody"].Value; string body = _localizer["TwoFactorEmailBody"].Value;
@ -377,7 +379,7 @@ namespace Oqtane.Managers
} }
else else
{ {
if (await _identityUserManager.IsEmailConfirmedAsync(identityuser)) if (!bool.Parse(_settings.GetSettingValue(EntityNames.Site, alias.SiteId, "LoginOptions:RequireValidEmail", "true")) || await _identityUserManager.IsEmailConfirmedAsync(identityuser))
{ {
user = GetUser(identityuser.UserName, alias.SiteId); user = GetUser(identityuser.UserName, alias.SiteId);
if (user != null) if (user != null)
@ -400,13 +402,25 @@ namespace Oqtane.Managers
} }
else else
{ {
_logger.Log(LogLevel.Information, this, LogFunction.Security, "User {Username} Is Not An Active Member Of Site {SiteId}", user.Username, alias.SiteId); _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Login Denied - User {Username} Is Not An Active Member Of Site {SiteId}", user.Username, alias.SiteId);
} }
} }
} }
else else
{ {
_logger.Log(LogLevel.Information, this, LogFunction.Security, "User Email Address Not Verified {Username}", user.Username); _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Login Denied - User Email Address Not Verified For {Username}", user.Username);
// send verification email again
string token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
string url = alias.Protocol + alias.Name + "/login?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token);
string subject = _localizer["VerificationEmailSubject"];
subject = subject.Replace("[SiteName]", siteName);
string body = _localizer["VerificationEmailBody"].Value;
body = body.Replace("[UserDisplayName]", user.DisplayName);
body = body.Replace("[URL]", url);
body = body.Replace("[SiteName]", siteName);
var notification = new Notification(alias.SiteId, user, subject, body);
_notifications.AddNotification(notification);
} }
} }
} }
@ -538,8 +552,7 @@ namespace Oqtane.Managers
if (user != null) if (user != null)
{ {
var alias = _tenantManager.GetAlias(); var alias = _tenantManager.GetAlias();
var twoFactorSetting = _settings.GetSetting(EntityNames.Site, alias.SiteId, "LoginOptions:TwoFactor")?.SettingValue ?? "false"; var twoFactorRequired = _settings.GetSettingValue(EntityNames.Site, alias.SiteId, "LoginOptions:TwoFactor", "false") == "required" || user.TwoFactorRequired;
var twoFactorRequired = twoFactorSetting == "required" || user.TwoFactorRequired;
if (twoFactorRequired && user.TwoFactorCode == token && DateTime.UtcNow < user.TwoFactorExpiry) if (twoFactorRequired && user.TwoFactorCode == token && DateTime.UtcNow < user.TwoFactorExpiry)
{ {
user.IsAuthenticated = true; user.IsAuthenticated = true;

View File

@ -16,5 +16,6 @@ namespace Oqtane.Repository
void DeleteSetting(string entityName, int settingId); void DeleteSetting(string entityName, int settingId);
void DeleteSettings(string entityName, int entityId); void DeleteSettings(string entityName, int entityId);
string GetSettingValue(IEnumerable<Setting> settings, string settingName, string defaultValue); string GetSettingValue(IEnumerable<Setting> settings, string settingName, string defaultValue);
string GetSettingValue(string entityName, int entityId, string settingName, string defaultValue);
} }
} }

View File

@ -180,6 +180,19 @@ namespace Oqtane.Repository
} }
} }
public string GetSettingValue(string entityName, int entityId, string settingName, string defaultValue)
{
var setting = GetSetting(entityName, entityId, settingName);
if (setting != null)
{
return setting.SettingValue;
}
else
{
return defaultValue;
}
}
private bool IsMaster(string EntityName) private bool IsMaster(string EntityName)
{ {
return (EntityName == EntityNames.ModuleDefinition || EntityName == EntityNames.Host); return (EntityName == EntityNames.ModuleDefinition || EntityName == EntityNames.Host);