user identity improvements

This commit is contained in:
sbwalker 2023-11-29 10:42:23 -05:00
parent c8ac4ec1e8
commit 3c33614115
16 changed files with 353 additions and 184 deletions

View File

@ -1,4 +1,5 @@
@namespace Oqtane.Modules.Admin.Register
@using System.Net
@inherits ModuleBase
@inject NavigationManager NavigationManager
@inject IUserService UserService
@ -88,9 +89,9 @@ else
}
protected override void OnParametersSet()
{
_togglepassword = SharedLocalizer["ShowPassword"];
}
{
_togglepassword = SharedLocalizer["ShowPassword"];
}
private async Task Register()
{
@ -120,7 +121,14 @@ else
if (user != null)
{
await logger.LogInformation("User Created {Username} {Email}", _username, _email);
AddModuleMessage(Localizer["Info.User.AccountCreate"], MessageType.Info);
if (PageState.QueryString.ContainsKey("returnurl"))
{
NavigationManager.NavigateTo(WebUtility.UrlDecode(PageState.QueryString["returnurl"]));
}
else // legacy behavior
{
AddModuleMessage(Localizer["Info.User.AccountCreate"], MessageType.Info);
}
}
else
{

View File

@ -1,4 +1,5 @@
@namespace Oqtane.Modules.Admin.UserProfile
@using System.Net
@using System.Text.RegularExpressions;
@inherits ModuleBase
@inject NavigationManager NavigationManager
@ -337,6 +338,11 @@
email = PageState.User.Email;
displayname = PageState.User.DisplayName;
if (string.IsNullOrEmpty(email))
{
AddModuleMessage(Localizer["Message.User.NoEmail"], MessageType.Warning);
}
// get user folder
var folder = await FolderService.GetFolderAsync(ModuleState.SiteId, PageState.User.FolderPath);
if (folder != null)
@ -427,8 +433,15 @@
await SettingService.UpdateUserSettingsAsync(settings, PageState.User.UserId);
await logger.LogInformation("User Profile Saved");
AddModuleMessage(Localizer["Success.Profile.Update"], MessageType.Success);
StateHasChanged();
if (PageState.QueryString.ContainsKey("returnurl"))
{
NavigationManager.NavigateTo(WebUtility.UrlDecode(PageState.QueryString["returnurl"]));
}
else // legacy behavior
{
AddModuleMessage(Localizer["Success.Profile.Update"], MessageType.Success);
StateHasChanged();
}
}
else
{

View File

@ -195,7 +195,7 @@ else
@if (_providertype != "")
{
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="providername" HelpText="The external login provider name which will be displayed on the login page" ResourceKey="ProviderName">Provider Name:</Label>
<Label Class="col-sm-3" For="providername" HelpText="Specify a friendly name for the external login provider which will be displayed on the Login page" ResourceKey="ProviderName">Provider Name:</Label>
<div class="col-sm-9">
<input id="providername" class="form-control" @bind="@_providername" />
</div>
@ -300,41 +300,50 @@ else
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="reviewclaims" HelpText="This option should only be used for testing. It allows the full list of Claims returned by the Provider to be recorded in the Event Log. Please note that external login is restricted when this option is enabled." ResourceKey="ReviewClaims">Review Claims?</Label>
<Label Class="col-sm-3" For="reviewclaims" HelpText="This option will record the full list of Claims returned by the Provider in the Event Log. It should only be used for testing purposes. External Login will be restricted when this option is enabled." ResourceKey="ReviewClaims">Review Claims?</Label>
<div class="col-sm-9">
<select id="reviewclaims" class="form-select" @bind="@_reviewclaims" required>
<option value="true">@SharedLocalizer["Yes"]</option>
<option value="false">@SharedLocalizer["No"]</option>
</select>
<div class="input-group">
<select id="reviewclaims" class="form-select" @bind="@_reviewclaims" required>
<option value="true">@SharedLocalizer["Yes"]</option>
<option value="false">@SharedLocalizer["No"]</option>
</select>
@if (_reviewclaims == "true")
{
<a href="@_externalloginurl" target="_blank" class="btn btn-secondary">@SharedLocalizer["Test"]</a>
}
</div>
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="identifierclaimtype" HelpText="The name of the unique user identifier claim provided by the provider" ResourceKey="IdentifierClaimType">Identifier Claim:</Label>
<Label Class="col-sm-3" For="identifierclaimtype" HelpText="Specify the type name of the unique user identifier claim provided by the provider. The default value is 'sub'." ResourceKey="IdentifierClaimType">Identifier Claim:</Label>
<div class="col-sm-9">
<input id="identifierclaimtype" class="form-control" @bind="@_identifierclaimtype" />
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="emailclaimtype" HelpText="The name of the email address claim provided by the provider" ResourceKey="EmailClaimType">Email Claim:</Label>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="nameclaimtype" HelpText="Optionally specify the type name of the user's name claim provided by the provider. The typical value is 'name'." ResourceKey="NameClaimType">Name Claim:</Label>
<div class="col-sm-9">
<input id="nameclaimtype" class="form-control" @bind="@_nameclaimtype" />
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="emailclaimtype" HelpText="Optionally specify the type name of the email address claim provided by the provider. The typical value is 'email'," ResourceKey="EmailClaimType">Email Claim:</Label>
<div class="col-sm-9">
<input id="emailclaimtype" class="form-control" @bind="@_emailclaimtype" />
</div>
</div>
@if (_providertype == AuthenticationProviderTypes.OpenIDConnect)
{
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="roleclaimtype" HelpText="The name of the role claim provided by the provider" ResourceKey="RoleClaimType">Role Claim:</Label>
<div class="col-sm-9">
<input id="roleclaimtype" class="form-control" @bind="@_roleclaimtype" />
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="roleclaimtype" HelpText="The name of the role claim provided by the provider" ResourceKey="RoleClaimType">Role Claim:</Label>
<div class="col-sm-9">
<input id="roleclaimtype" class="form-control" @bind="@_roleclaimtype" />
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="profileclaimtypes" HelpText="A comma delimited list of user profile claims provided by the provider, as well as mappings to your user profile definition. For example if the provider includes a 'given_name' claim and you have a 'FirstName' user profile definition you should specify 'given_name:FirstName'." ResourceKey="ProfileClaimTypes">User Profile Claims:</Label>
<div class="col-sm-9">
<input id="profileclaimtypes" class="form-control" @bind="@_profileclaimtypes" />
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="profileclaimtypes" HelpText="A comma delimited list of user profile claims provided by the provider, as well as mappings to your user profile definition. For example if the provider includes a 'given_name' claim and you have a 'FirstName' user profile definition you should specify 'given_name:FirstName'." ResourceKey="ProfileClaimTypes">User Profile Claims:</Label>
<div class="col-sm-9">
<input id="profileclaimtypes" class="form-control" @bind="@_profileclaimtypes" />
</div>
}
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="domainfilter" HelpText="Provide any email domain filter criteria (separated by commas). Domains to exclude should be prefixed with an exclamation point (!). For example 'microsoft.com,!hotmail.com' would include microsoft.com email addresses but not hotmail.com email addresses." ResourceKey="DomainFilter">Domain Filter:</Label>
<div class="col-sm-9">
@ -443,7 +452,9 @@ else
private string _pkce;
private string _redirecturl;
private string _reviewclaims;
private string _externalloginurl;
private string _identifierclaimtype;
private string _nameclaimtype;
private string _emailclaimtype;
private string _roleclaimtype;
private string _profileclaimtypes;
@ -505,7 +516,9 @@ else
_pkce = SettingService.GetSetting(settings, "ExternalLogin:PKCE", "false");
_redirecturl = PageState.Uri.Scheme + "://" + PageState.Alias.Name + "/signin-" + _providertype;
_reviewclaims = SettingService.GetSetting(settings, "ExternalLogin:ReviewClaims", "false");
_externalloginurl = Utilities.TenantUrl(PageState.Alias, "/pages/external");
_identifierclaimtype = SettingService.GetSetting(settings, "ExternalLogin:IdentifierClaimType", "sub");
_nameclaimtype = SettingService.GetSetting(settings, "ExternalLogin:NameClaimType", "name");
_emailclaimtype = SettingService.GetSetting(settings, "ExternalLogin:EmailClaimType", "email");
_roleclaimtype = SettingService.GetSetting(settings, "ExternalLogin:RoleClaimType", "");
_profileclaimtypes = SettingService.GetSetting(settings, "ExternalLogin:ProfileClaimTypes", "");
@ -598,7 +611,8 @@ else
settings = SettingService.SetSetting(settings, "ExternalLogin:PKCE", _pkce, true);
settings = SettingService.SetSetting(settings, "ExternalLogin:ReviewClaims", _reviewclaims, true);
settings = SettingService.SetSetting(settings, "ExternalLogin:IdentifierClaimType", _identifierclaimtype, true);
settings = SettingService.SetSetting(settings, "ExternalLogin:EmailClaimType", _emailclaimtype, true);
settings = SettingService.SetSetting(settings, "ExternalLogin:NameClaimType", _nameclaimtype, true);
settings = SettingService.SetSetting(settings, "ExternalLogin:EmailClaimType", _emailclaimtype, true);
settings = SettingService.SetSetting(settings, "ExternalLogin:RoleClaimType", _roleclaimtype, true);
settings = SettingService.SetSetting(settings, "ExternalLogin:ProfileClaimTypes", _profileclaimtypes, true);
settings = SettingService.SetSetting(settings, "ExternalLogin:DomainFilter", _domainfilter, true);

View File

@ -204,8 +204,8 @@
<data name="ExternalLoginStatus.DuplicateEmail" xml:space="preserve">
<value>Multiple User Accounts Already Exist With The Email Address Of Your External Login. Please Contact Your Administrator For Further Instructions.</value>
</data>
<data name="ExternalLoginStatus.InvalidEmail" xml:space="preserve">
<value>The External Login Provider Did Not Provide A Valid Email Address For Your Account. Please Contact Your Administrator For Further Instructions.</value>
<data name="ExternalLoginStatus.MissingClaims" xml:space="preserve">
<value>The External Login Provider Did Not Provide All Of The Required Information. Please Contact Your Administrator For Further Instructions.</value>
</data>
<data name="ExternalLoginStatus.ProviderKeyMismatch" xml:space="preserve">
<value>An Error Occurred Verifying Your External Login. Please Contact Your Administrator For Further Instructions.</value>

View File

@ -147,6 +147,9 @@
<data name="Message.User.NoLogIn" xml:space="preserve">
<value>Current User Is Not Logged In</value>
</data>
<data name="Message.User.NoEmail" xml:space="preserve">
<value>You Must Provide An Email Address For Your User Account</value>
</data>
<data name="Error.Profile.Load" xml:space="preserve">
<value>Error Loading User Profile</value>
</data>

View File

@ -247,7 +247,7 @@
<value>Domain Filter:</value>
</data>
<data name="EmailClaimType.HelpText" xml:space="preserve">
<value>The name of the email address claim provided by the identity provider</value>
<value>Optionally specify the type name of the email address claim provided by the identity provider. The typical value is 'email'.</value>
</data>
<data name="EmailClaimType.Text" xml:space="preserve">
<value>Email Claim:</value>
@ -274,7 +274,7 @@
<value>Use PKCE?</value>
</data>
<data name="ProviderName.HelpText" xml:space="preserve">
<value>The external login provider name which will be displayed on the login page</value>
<value>Specify a friendly name for the external login provider which will be displayed on the Login page</value>
</data>
<data name="ProviderName.Text" xml:space="preserve">
<value>Provider Name:</value>
@ -373,7 +373,7 @@
<value>Last Login</value>
</data>
<data name="IdentifierClaimType.HelpText" xml:space="preserve">
<value>The name of the unique user identifier claim provided by the identity provider</value>
<value>Specify the type name of the unique user identifier claim provided by the identity provider. The default value is 'sub'.</value>
</data>
<data name="IdentifierClaimType.Text" xml:space="preserve">
<value>Identifier Claim:</value>
@ -385,13 +385,13 @@
<value>Parameters:</value>
</data>
<data name="RoleClaimType.HelpText" xml:space="preserve">
<value>Optionally provide the name of the role claim provided by the identity provider. These roles will be used in addition to any internal user roles assigned within the site.</value>
<value>Optionally provide the type name of the role claim provided by the identity provider. These roles will be used in addition to any internal user roles assigned within the site.</value>
</data>
<data name="RoleClaimType.Text" xml:space="preserve">
<value>Role Claim:</value>
</data>
<data name="ProfileClaimTypes.HelpText" xml:space="preserve">
<value>Optionally provide a comma delimited list of user profile claims provided by the identity provider, as well as mappings to your user profile definition. For example if the identity provider includes a 'given_name' claim and you have a 'FirstName' user profile definition you should specify 'given_name:FirstName'.</value>
<value>Optionally provide a comma delimited list of user profile claim type names provided by the identity provider, as well as mappings to your user profile definition. For example if the identity provider includes a 'given_name' claim and you have a 'FirstName' user profile definition you should specify 'given_name:FirstName'.</value>
</data>
<data name="ProfileClaimTypes.Text" xml:space="preserve">
<value>User Profile Claims:</value>
@ -460,6 +460,12 @@
<value>Review Claims?</value>
</data>
<data name="ReviewClaims.HelpText" xml:space="preserve">
<value>This option should only be used for testing. It allows the full list of Claims returned by the Provider to be recorded in the Event Log. Please note that external login is restricted when this option is enabled.</value>
<value>This option will record the full list of Claims returned by the Provider in the Event Log. It should only be used for testing purposes. External Login will be restricted when this option is enabled.</value>
</data>
<data name="NameClaimType.HelpText" xml:space="preserve">
<value>Optionally specify the type name of the user's name claim provided by the identity provider. The typical value is 'name'.</value>
</data>
<data name="NameClaimType.Text" xml:space="preserve">
<value>Name Claim:</value>
</data>
</root>

View File

@ -435,4 +435,7 @@
<data name="Uninstall" xml:space="preserve">
<value>Uninstall</value>
</data>
<data name="Test" xml:space="preserve">
<value>Test</value>
</data>
</root>

View File

@ -26,8 +26,7 @@ namespace Oqtane.Themes.Controls
var allowexternallogin = (SettingService.GetSetting(PageState.Site.Settings, "ExternalLogin:ProviderType", "") != "") ? true : false;
var allowsitelogin = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AllowSiteLogin", "true"));
Route route = new Route(PageState.Uri.AbsoluteUri, PageState.Alias.Path);
var returnurl = WebUtility.UrlEncode(route.PathAndQuery);
var returnurl = WebUtility.UrlEncode(PageState.Route.PathAndQuery);
if (allowexternallogin && !allowsitelogin)
{
@ -39,7 +38,6 @@ namespace Oqtane.Themes.Controls
// local login
NavigationManager.NavigateTo(NavigateUrl("login", "?returnurl=" + returnurl));
}
}
protected async Task LogoutUser()

View File

@ -1,4 +1,5 @@
@namespace Oqtane.Themes.Controls
@using System.Net
@inherits ThemeControlBase
@inject IStringLocalizer<UserProfile> Localizer
@ -26,14 +27,21 @@
[Parameter]
public bool ShowRegister { get; set; }
private string _returnurl = "";
protected override void OnParametersSet()
{
_returnurl = WebUtility.UrlEncode(PageState.Route.PathAndQuery);
}
private void RegisterUser()
{
NavigationManager.NavigateTo(NavigateUrl("register"));
NavigationManager.NavigateTo(NavigateUrl("register", "returnurl=" + _returnurl));
}
private void UpdateProfile()
{
NavigationManager.NavigateTo(NavigateUrl("profile"));
NavigationManager.NavigateTo(NavigateUrl("profile", "returnurl=" + _returnurl));
}
}

View File

@ -1,4 +1,5 @@
@namespace Oqtane.UI
@using System.Net
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
@inject SiteState SiteState
@ -87,6 +88,13 @@
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// force user to provide email address (email may be missing if using external login)
if (PageState.User != null && string.IsNullOrEmpty(PageState.User.Email) && PageState.Route.PagePath != "profile")
{
NavigationManager.NavigateTo(Utilities.NavigateUrl(PageState.Alias.Path, "profile", "returnurl=" + WebUtility.UrlEncode(PageState.Route.PathAndQuery)));
return;
}
if (!firstRender)
{
if (!string.IsNullOrEmpty(PageState.Page.HeadContent) && PageState.Page.HeadContent.Contains("<script"))

View File

@ -176,12 +176,13 @@ namespace Oqtane.Controllers
if (ModelState.IsValid && user.SiteId == _tenantManager.GetAlias().SiteId && user.UserId == id && _users.GetUser(user.UserId, false) != null
&& (_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || User.Identity.Name == user.Username))
{
user.EmailConfirmed = User.IsInRole(RoleNames.Admin);
user = await _userManager.UpdateUser(user);
}
else
{
user.Password = ""; // remove sensitive information
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Post Attempt {User}", user);
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Put Attempt {User}", user);
HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden;
user = null;
}

View File

@ -179,7 +179,7 @@ namespace Microsoft.Extensions.DependencyInjection
options.Lockout.AllowedForNewUsers = false;
// SignIn settings
options.SignIn.RequireConfirmedEmail = true;
options.SignIn.RequireConfirmedEmail = true;
options.SignIn.RequireConfirmedPhoneNumber = false;
// User settings

View File

@ -150,15 +150,16 @@ namespace Oqtane.Extensions
private static async Task OnCreatingTicket(OAuthCreatingTicketContext context)
{
// OAuth 2.0
var email = "";
var id = "";
var claims = "";
var id = "";
var name = "";
var email = "";
if (context.Options.UserInformationEndpoint != "")
{
try
{
// call user information endpoint
// call user information endpoint using access token
var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.UserAgent.Add(new ProductInfoHeaderValue(Constants.PackageId, Constants.Version));
@ -167,32 +168,57 @@ namespace Oqtane.Extensions
response.EnsureSuccessStatusCode();
claims = await response.Content.ReadAsStringAsync();
// parse json output
// get claim types
var idClaimType = context.HttpContext.GetSiteSettings().GetValue("ExternalLogin:IdentifierClaimType", "");
var nameClaimType = context.HttpContext.GetSiteSettings().GetValue("ExternalLogin:NameClaimType", "");
var emailClaimType = context.HttpContext.GetSiteSettings().GetValue("ExternalLogin:EmailClaimType", "");
if (!claims.StartsWith("[") && !claims.EndsWith("]"))
// some user endpoints can return multiple objects (ie. GitHub) so convert single object to array (if necessary)
var jsonclaims = claims;
if (!jsonclaims.StartsWith("[") && !jsonclaims.EndsWith("]"))
{
claims = "[" + claims + "]"; // convert to json array
jsonclaims = "[" + jsonclaims + "]";
}
JsonNode items = JsonNode.Parse(claims)!;
// parse claim values
JsonNode items = JsonNode.Parse(jsonclaims)!;
foreach (var item in items.AsArray())
{
if (item[emailClaimType] != null)
// id claim is required
if (!string.IsNullOrEmpty(idClaimType) && item[idClaimType] != null)
{
if (EmailValid(item[emailClaimType].ToString(), context.HttpContext.GetSiteSettings().GetValue("ExternalLogin:DomainFilter", "")))
id = item[idClaimType].ToString();
// name claim is optional
if (!string.IsNullOrEmpty(nameClaimType))
{
email = item[emailClaimType].ToString().ToLower();
if (item[idClaimType] != null)
if (item[nameClaimType] != null)
{
id = item[idClaimType].ToString();
name = item[nameClaimType].ToString();
}
else
{
id = ""; // name claim was specified but was not provided
}
}
// email claim is optional
if (!string.IsNullOrEmpty(emailClaimType))
{
if (item[emailClaimType] != null && EmailValid(item[emailClaimType].ToString(), context.HttpContext.GetSiteSettings().GetValue("ExternalLogin:DomainFilter", "")))
{
email = item[emailClaimType].ToString().ToLower();
}
else
{
id = ""; // email claim was specified but was not provided or is invalid
}
break;
}
}
}
if (string.IsNullOrEmpty(id))
{
id = email;
if (!string.IsNullOrEmpty(id))
{
break;
}
}
}
catch (Exception ex)
@ -203,7 +229,7 @@ namespace Oqtane.Extensions
}
// validate user
var identity = await ValidateUser(email, id, claims, context.HttpContext, context.Principal);
var identity = await ValidateUser(id, name, email, claims, context.HttpContext, context.Principal);
if (identity.Label == ExternalLoginStatus.Success)
{
identity.AddClaim(new Claim("access_token", context.AccessToken));
@ -231,28 +257,53 @@ namespace Oqtane.Extensions
private static async Task OnTokenValidated(TokenValidatedContext context)
{
// OpenID Connect
var claims = "";
var id = "";
var name = "";
var email = "";
// serialize claims
foreach (var claim in context.Principal.Claims)
{
claims += "\"" + claim.Type + "\":\"" + claim.Value + "\",";
}
claims = "{" + claims.Substring(0, claims.Length - 1) + "}";
// get claim types
var idClaimType = context.HttpContext.GetSiteSettings().GetValue("ExternalLogin:IdentifierClaimType", "");
var id = context.Principal.FindFirstValue(idClaimType);
var nameClaimType = context.HttpContext.GetSiteSettings().GetValue("ExternalLogin:NameClaimType", "");
var emailClaimType = context.HttpContext.GetSiteSettings().GetValue("ExternalLogin:EmailClaimType", "");
var email = context.Principal.FindFirstValue(emailClaimType);
var claims = string.Join(", ", context.Principal.Claims.Select(item => item.Type).ToArray());
// parse claim values
id = context.Principal.FindFirstValue(idClaimType); // required
if (!string.IsNullOrEmpty(nameClaimType))
{
if (context.Principal.FindFirstValue(nameClaimType) != null)
{
name = context.Principal.FindFirstValue(nameClaimType);
}
else
{
id = ""; // name claim was specified but was not provided
}
}
if (!string.IsNullOrEmpty(emailClaimType))
{
if (context.Principal.FindFirstValue(emailClaimType) != null && EmailValid(context.Principal.FindFirstValue(emailClaimType), context.HttpContext.GetSiteSettings().GetValue("ExternalLogin:DomainFilter", "")))
{
email = context.Principal.FindFirstValue(emailClaimType);
}
else
{
id = ""; // email claim was specified but was not provided or is invalid
}
}
// validate user
var identity = await ValidateUser(email, id, claims, context.HttpContext, context.Principal);
var identity = await ValidateUser(id, name, email, claims, context.HttpContext, context.Principal);
if (identity.Label == ExternalLoginStatus.Success)
{
// external roles
if (!string.IsNullOrEmpty(context.HttpContext.GetSiteSettings().GetValue("ExternalLogin:RoleClaimType", "")))
{
foreach (var claim in context.Principal.Claims.Where(item => item.Type == ClaimTypes.Role))
{
if (!identity.Claims.Any(item => item.Type == ClaimTypes.Role && item.Value == claim.Value))
{
identity.AddClaim(new Claim(ClaimTypes.Role, claim.Value));
}
}
}
// include access token
identity.AddClaim(new Claim("access_token", context.SecurityToken.RawData));
context.Principal = new ClaimsPrincipal(identity);
}
@ -284,13 +335,13 @@ namespace Oqtane.Extensions
return Task.CompletedTask;
}
private static async Task<ClaimsIdentity> ValidateUser(string email, string id, string claims, HttpContext httpContext, ClaimsPrincipal claimsPrincipal)
private static async Task<ClaimsIdentity> ValidateUser(string id, string name, string email, string claims, HttpContext httpContext, ClaimsPrincipal claimsPrincipal)
{
var _logger = httpContext.RequestServices.GetRequiredService<ILogManager>();
ClaimsIdentity identity = new ClaimsIdentity(Constants.AuthenticationScheme);
// use identity.Label as a temporary location to store validation status information
// review claims option (for testing)
// review claims feature (for testing - external login is disabled)
if (bool.Parse(httpContext.GetSiteSettings().GetValue("ExternalLogin:ReviewClaims", "false")))
{
_logger.Log(LogLevel.Information, "ExternalLogin", Enums.LogFunction.Security, "Provider Returned The Following Claims: {Claims}", claims);
@ -316,136 +367,158 @@ namespace Oqtane.Extensions
}
else
{
if (EmailValid(email, httpContext.GetSiteSettings().GetValue("ExternalLogin:DomainFilter", "")))
bool duplicates = false;
if (!string.IsNullOrEmpty(email))
{
bool duplicates = false;
try
{
identityuser = await _identityUserManager.FindByEmailAsync(email);
}
catch
{
// FindByEmailAsync will throw an error if the email matches multiple user accounts
catch // FindByEmailAsync will throw an error if the email matches multiple user accounts
{
duplicates = true;
}
if (identityuser == null)
}
if (identityuser == null)
{
if (duplicates)
{
if (duplicates)
identity.Label = ExternalLoginStatus.DuplicateEmail;
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "Multiple Users Exist With Email Address {Email}. Login Denied.", email);
}
else
{
if (bool.Parse(httpContext.GetSiteSettings().GetValue("ExternalLogin:CreateUsers", "true")))
{
identity.Label = ExternalLoginStatus.DuplicateEmail;
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "Multiple Users Exist With Email Address {Email}. Login Denied.", email);
}
else
{
if (bool.Parse(httpContext.GetSiteSettings().GetValue("ExternalLogin:CreateUsers", "true")))
{
identityuser = new IdentityUser();
identityuser.UserName = email;
identityuser.Email = email;
identityuser.EmailConfirmed = true;
var result = await _identityUserManager.CreateAsync(identityuser, DateTime.UtcNow.ToString("yyyy-MMM-dd-HH-mm-ss", CultureInfo.InvariantCulture));
if (result.Succeeded)
{
user = new User
{
SiteId = alias.SiteId,
Username = email,
DisplayName = email,
Email = email,
LastLoginOn = null,
LastIPAddress = ""
};
user = _users.AddUser(user);
// user identifiers
var username = "";
var emailaddress = "";
var displayname = "";
bool emailconfirmed = false;
if (user != null)
if (!string.IsNullOrEmpty(email)) // email claim provided
{
username = email;
emailaddress = email;
displayname = (!string.IsNullOrEmpty(name)) ? name : email;
emailconfirmed = true;
}
else if (!string.IsNullOrEmpty(name)) // name claim provided
{
username = name.ToLower().Replace(" ", "") + DateTime.UtcNow.ToString("mmss");
emailaddress = ""; // unknown - will need to be requested from user later
displayname = name;
}
else // neither email nor name provided
{
username = Guid.NewGuid().ToString("N");
emailaddress = ""; // unknown - will need to be requested from user later
displayname = username;
}
identityuser = new IdentityUser();
identityuser.UserName = username;
identityuser.Email = emailaddress;
identityuser.EmailConfirmed = emailconfirmed;
// generate password based on random date and punctuation ie. Jan-23-1981+14:43:12!
Random rnd = new Random();
var date = DateTime.UtcNow.AddDays(-rnd.Next(50 * 365)).AddHours(rnd.Next(0, 24)).AddMinutes(rnd.Next(0, 60)).AddSeconds(rnd.Next(0, 60));
var password = date.ToString("MMM-dd-yyyy+HH:mm:ss", CultureInfo.InvariantCulture) + (char)rnd.Next(33, 47);
var result = await _identityUserManager.CreateAsync(identityuser, password);
if (result.Succeeded)
{
user = new User
{
SiteId = alias.SiteId,
Username = username,
DisplayName = displayname,
Email = emailaddress,
LastLoginOn = null,
LastIPAddress = ""
};
user = _users.AddUser(user);
if (user != null)
{
if (!string.IsNullOrEmpty(email))
{
var _notifications = httpContext.RequestServices.GetRequiredService<INotificationRepository>();
string url = httpContext.Request.Scheme + "://" + alias.Name;
string body = "You Recently Used An External Account To Sign In To Our Site.\n\n" + url + "\n\nThank You!";
var notification = new Notification(user.SiteId, user, "User Account Notification", body);
_notifications.AddNotification(notification);
// add user login
await _identityUserManager.AddLoginAsync(identityuser, new UserLoginInfo(providerType + ":" + user.SiteId.ToString(), id, providerName));
_logger.Log(user.SiteId, LogLevel.Information, "ExternalLogin", Enums.LogFunction.Create, "User Added {User}", user);
}
else
{
identity.Label = ExternalLoginStatus.UserNotCreated;
_logger.Log(alias.SiteId, LogLevel.Error, "ExternalLogin", Enums.LogFunction.Create, "Unable To Add User {Email}", email);
}
// add user login
await _identityUserManager.AddLoginAsync(identityuser, new UserLoginInfo(providerType + ":" + user.SiteId.ToString(), id, providerName));
_logger.Log(user.SiteId, LogLevel.Information, "ExternalLogin", Enums.LogFunction.Create, "User Added {User}", user);
}
else
{
identity.Label = ExternalLoginStatus.UserNotCreated;
_logger.Log(alias.SiteId, LogLevel.Error, "ExternalLogin", Enums.LogFunction.Create, "Unable To Add Identity User {Email} {Error}", email, result.Errors.ToString());
_logger.Log(alias.SiteId, LogLevel.Error, "ExternalLogin", Enums.LogFunction.Create, "Unable To Add User {Email}", email);
}
}
else
{
identity.Label = ExternalLoginStatus.UserDoesNotExist;
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "Creation Of New Users Is Disabled For This Site. User With Email Address {Email} Will First Need To Be Registered On The Site.", email);
}
}
}
else
{
var logins = await _identityUserManager.GetLoginsAsync(identityuser);
var login = logins.FirstOrDefault(item => item.LoginProvider == (providerType + ":" + alias.SiteId.ToString()));
if (login == null)
{
if (bool.Parse(httpContext.GetSiteSettings().GetValue("ExternalLogin:VerifyUsers", "true")))
{
// external login using existing user account - verification required
var _notifications = httpContext.RequestServices.GetRequiredService<INotificationRepository>();
string token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
string url = httpContext.Request.Scheme + "://" + alias.Name;
url += $"/login?name={identityuser.UserName}&token={WebUtility.UrlEncode(token)}&key={WebUtility.UrlEncode(id)}";
string body = $"You Recently Signed In To Our Site With {providerName} Using The Email Address {email}. ";
body += "In Order To Complete The Linkage Of Your User Account Please Click The Link Displayed Below:\n\n" + url + "\n\nThank You!";
var notification = new Notification(alias.SiteId, email, email, "External Login Linkage", body);
_notifications.AddNotification(notification);
identity.Label = ExternalLoginStatus.VerificationRequired;
_logger.Log(alias.SiteId, LogLevel.Information, "ExternalLogin", Enums.LogFunction.Create, "External Login Linkage Verification For Provider {Provider} Sent To {Email}", providerName, email);
}
else
{
// external login using existing user account - link automatically
user = _users.GetUser(identityuser.UserName);
user.SiteId = alias.SiteId;
var _notifications = httpContext.RequestServices.GetRequiredService<INotificationRepository>();
string url = httpContext.Request.Scheme + "://" + alias.Name;
string body = "You Recently Used An External Account To Sign In To Our Site.\n\n" + url + "\n\nThank You!";
var notification = new Notification(user.SiteId, user, "User Account Notification", body);
_notifications.AddNotification(notification);
// add user login
await _identityUserManager.AddLoginAsync(identityuser, new UserLoginInfo(providerType + ":" + user.SiteId.ToString(), id, providerName));
_logger.Log(user.SiteId, LogLevel.Information, "ExternalLogin", Enums.LogFunction.Create, "External Login Linkage Created For User {Username} And Provider {Provider}", user.Username, providerName);
identity.Label = ExternalLoginStatus.UserNotCreated;
_logger.Log(alias.SiteId, LogLevel.Error, "ExternalLogin", Enums.LogFunction.Create, "Unable To Add Identity User {Email} {Error}", email, result.Errors.ToString());
}
}
else
{
// provider keys do not match
identity.Label = ExternalLoginStatus.ProviderKeyMismatch;
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "Provider Key Does Not Match For User {Username}. Login Denied.", identityuser.UserName);
identity.Label = ExternalLoginStatus.UserDoesNotExist;
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "Creation Of New Users Is Disabled For This Site. User With Email Address {Email} Will First Need To Be Registered On The Site.", email);
}
}
}
else // email invalid
else
{
identity.Label = ExternalLoginStatus.InvalidEmail;
if (!string.IsNullOrEmpty(email))
var logins = await _identityUserManager.GetLoginsAsync(identityuser);
var login = logins.FirstOrDefault(item => item.LoginProvider == (providerType + ":" + alias.SiteId.ToString()));
if (login == null)
{
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "The Email Address {Email} Is Invalid Or Does Not Match The Domain Filter Criteria. Login Denied.", email);
if (bool.Parse(httpContext.GetSiteSettings().GetValue("ExternalLogin:VerifyUsers", "true")))
{
// external login using existing user account - verification required
var _notifications = httpContext.RequestServices.GetRequiredService<INotificationRepository>();
string token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
string url = httpContext.Request.Scheme + "://" + alias.Name;
url += $"/login?name={identityuser.UserName}&token={WebUtility.UrlEncode(token)}&key={WebUtility.UrlEncode(id)}";
string body = $"You Recently Signed In To Our Site With {providerName} Using The Email Address {email}. ";
body += "In Order To Complete The Linkage Of Your User Account Please Click The Link Displayed Below:\n\n" + url + "\n\nThank You!";
var notification = new Notification(alias.SiteId, email, email, "External Login Linkage", body);
_notifications.AddNotification(notification);
identity.Label = ExternalLoginStatus.VerificationRequired;
_logger.Log(alias.SiteId, LogLevel.Information, "ExternalLogin", Enums.LogFunction.Create, "External Login Linkage Verification For Provider {Provider} Sent To {Email}", providerName, email);
}
else
{
// external login using existing user account - link automatically
user = _users.GetUser(identityuser.UserName);
user.SiteId = alias.SiteId;
var _notifications = httpContext.RequestServices.GetRequiredService<INotificationRepository>();
string url = httpContext.Request.Scheme + "://" + alias.Name;
string body = "You Recently Used An External Account To Sign In To Our Site.\n\n" + url + "\n\nThank You!";
var notification = new Notification(user.SiteId, user, "User Account Notification", body);
_notifications.AddNotification(notification);
// add user login
await _identityUserManager.AddLoginAsync(identityuser, new UserLoginInfo(providerType + ":" + user.SiteId.ToString(), id, providerName));
_logger.Log(user.SiteId, LogLevel.Information, "ExternalLogin", Enums.LogFunction.Create, "External Login Linkage Created For User {Username} And Provider {Provider}", user.Username, providerName);
}
}
else
{
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "Provider Did Not Return An Email Address To Uniquely Identify The User. The Email Claim Specified Was {EmailCLaimType} And Actual Claim Types Are {Claims}. Login Denied.", httpContext.GetSiteSettings().GetValue("ExternalLogin:EmailClaimType", ""), claims);
// provider keys do not match
identity.Label = ExternalLoginStatus.ProviderKeyMismatch;
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "Provider Key Does Not Match For User {Username}. Login Denied.", identityuser.UserName);
}
}
}
@ -463,6 +536,25 @@ namespace Oqtane.Extensions
user.LastIPAddress = httpContext.Connection.RemoteIpAddress.ToString();
_users.UpdateUser(user);
// external roles
if (!string.IsNullOrEmpty(httpContext.GetSiteSettings().GetValue("ExternalLogin:RoleClaimType", "")))
{
if (claimsPrincipal.Claims.Any(item => item.Type == ClaimTypes.Role))
{
foreach (var claim in claimsPrincipal.Claims.Where(item => item.Type == ClaimTypes.Role))
{
if (!identity.Claims.Any(item => item.Type == ClaimTypes.Role && item.Value == claim.Value))
{
identity.AddClaim(new Claim(ClaimTypes.Role, claim.Value));
}
}
}
else
{
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "The Role Claim {ClaimType} Does Not Exist. Please Use The Review Claims Feature To View The Claims Returned By Your Provider.", httpContext.GetSiteSettings().GetValue("ExternalLogin:RoleClaimType", ""));
}
}
// user profile claims
if (!string.IsNullOrEmpty(httpContext.GetSiteSettings().GetValue("ExternalLogin:ProfileClaimTypes", "")))
{
@ -501,7 +593,7 @@ namespace Oqtane.Extensions
}
else
{
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "The User Profile Claim {ClaimType} Does Not Exist. The Valid Claims Are {Claims}.", mapping.Split(":")[0], claims);
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "The User Profile Claim {ClaimType} Does Not Exist. Please Use The Review Claims Feature To View The Claims Returned By Your Provider.", mapping.Split(":")[0]);
}
}
else
@ -514,9 +606,10 @@ namespace Oqtane.Extensions
_logger.Log(LogLevel.Information, "ExternalLogin", Enums.LogFunction.Security, "External User Login Successful For {Username} Using Provider {Provider}", user.Username, providerName);
}
}
else // id invalid
else // claims invalid
{
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "Provider Did Not Return An Identifier To Uniquely Identify The User. The Identifier Claim Specified Was {IdentifierCLaimType} And Actual Claim Types Are {Claims}. Login Denied.", httpContext.GetSiteSettings().GetValue("ExternalLogin:IdentifierClaimType", ""), claims);
identity.Label = ExternalLoginStatus.MissingClaims;
_logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "Provider Did Not Return All Of The Claims Types Specified Or Email Address Does Not Saitisfy Domain Filter. The Actual Claims Returned Were {Claims}. Login Was Denied.", claims);
}
return identity;

View File

@ -106,7 +106,7 @@ namespace Oqtane.Managers
{
if (string.IsNullOrEmpty(user.Password))
{
// create random interal password based on random date and punctuation ie. Jan-23-1981+14:43:12!
// generate password based on random date and punctuation ie. Jan-23-1981+14:43:12!
Random rnd = new Random();
var date = DateTime.UtcNow.AddDays(-rnd.Next(50 * 365)).AddHours(rnd.Next(0, 24)).AddMinutes(rnd.Next(0, 60)).AddSeconds(rnd.Next(0, 60));
user.Password = date.ToString("MMM-dd-yyyy+HH:mm:ss", CultureInfo.InvariantCulture) + (char)rnd.Next(33, 47);
@ -152,7 +152,7 @@ namespace Oqtane.Managers
{
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 Complete The Registration Of Your User Account Please Click The Link Displayed Below:\n\n" + url + "\n\nThank You!";
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);
}
@ -205,8 +205,22 @@ namespace Oqtane.Managers
if (user.Email != identityuser.Email)
{
await _identityUserManager.SetEmailAsync(identityuser, user.Email);
var emailConfirmationToken = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
await _identityUserManager.ConfirmEmailAsync(identityuser, emailConfirmationToken);
// if email address changed and user is not administrator, email verification is required for new email address
if (!user.EmailConfirmed)
{
var alias = _tenantManager.GetAlias();
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);
}
else
{
var emailConfirmationToken = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
await _identityUserManager.ConfirmEmailAsync(identityuser, emailConfirmationToken);
}
}
user = _users.UpdateUser(user);
@ -308,7 +322,7 @@ namespace Oqtane.Managers
user = _users.GetUser(identityuser.UserName);
if (user != null)
{
if (identityuser.EmailConfirmed)
if (await _identityUserManager.IsEmailConfirmedAsync(identityuser))
{
user.IsAuthenticated = true;
user.LastLoginOn = DateTime.UtcNow;
@ -323,7 +337,7 @@ namespace Oqtane.Managers
}
else
{
_logger.Log(LogLevel.Information, this, LogFunction.Security, "User Not Verified {Username}", user.Username);
_logger.Log(LogLevel.Information, this, LogFunction.Security, "User Email Address Not Verified {Username}", user.Username);
}
}
}

View File

@ -19,7 +19,7 @@ namespace Oqtane.Pages
var providertype = HttpContext.GetSiteSettings().GetValue("ExternalLogin:ProviderType", "");
if (providertype != "")
{
return new ChallengeResult(providertype, new AuthenticationProperties { RedirectUri = returnurl + (returnurl.Contains("?") ? "&" : "?") + "reload=post" });
return new ChallengeResult(providertype, new AuthenticationProperties { RedirectUri = returnurl + (returnurl.Contains("?") ? "&" : "?") + "reload=post" });
}
else
{

View File

@ -1,7 +1,7 @@
namespace Oqtane.Shared {
public class ExternalLoginStatus {
public const string Success = "Success";
public const string InvalidEmail = "InvalidEmail";
public const string MissingClaims = "MissingClaims";
public const string DuplicateEmail = "DuplicateEmail";
public const string UserNotCreated = "UserNotCreated";
public const string UserDoesNotExist = "UserDoesNotExist";