From 3c33614115a6bf61c8f71b4b15d2e975f6c83cec Mon Sep 17 00:00:00 2001 From: sbwalker Date: Wed, 29 Nov 2023 10:42:23 -0500 Subject: [PATCH] user identity improvements --- .../Modules/Admin/Register/Index.razor | 16 +- .../Modules/Admin/UserProfile/Index.razor | 17 +- Oqtane.Client/Modules/Admin/Users/Index.razor | 60 +-- .../Resources/Modules/Admin/Login/Index.resx | 4 +- .../Modules/Admin/UserProfile/Index.resx | 3 + .../Resources/Modules/Admin/Users/Index.resx | 18 +- Oqtane.Client/Resources/SharedResources.resx | 3 + .../Themes/Controls/Theme/LoginBase.cs | 4 +- .../Themes/Controls/Theme/UserProfile.razor | 12 +- Oqtane.Client/UI/ThemeBuilder.razor | 8 + Oqtane.Server/Controllers/UserController.cs | 3 +- .../OqtaneServiceCollectionExtensions.cs | 2 +- ...taneSiteAuthenticationBuilderExtensions.cs | 357 +++++++++++------- Oqtane.Server/Managers/UserManager.cs | 26 +- Oqtane.Server/Pages/External.cshtml.cs | 2 +- Oqtane.Shared/Shared/ExternalLoginStatus.cs | 2 +- 16 files changed, 353 insertions(+), 184 deletions(-) diff --git a/Oqtane.Client/Modules/Admin/Register/Index.razor b/Oqtane.Client/Modules/Admin/Register/Index.razor index 2cf45dc9..b7c80851 100644 --- a/Oqtane.Client/Modules/Admin/Register/Index.razor +++ b/Oqtane.Client/Modules/Admin/Register/Index.razor @@ -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 { diff --git a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor index 58cc901e..feb8471f 100644 --- a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor +++ b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor @@ -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 { diff --git a/Oqtane.Client/Modules/Admin/Users/Index.razor b/Oqtane.Client/Modules/Admin/Users/Index.razor index 3d08c8b5..cf084b46 100644 --- a/Oqtane.Client/Modules/Admin/Users/Index.razor +++ b/Oqtane.Client/Modules/Admin/Users/Index.razor @@ -195,7 +195,7 @@ else @if (_providertype != "") {
- +
@@ -300,41 +300,50 @@ else
- +
- +
+ + @if (_reviewclaims == "true") + { + @SharedLocalizer["Test"] + } +
- +
-
- +
+ +
+ +
+
+
+
- @if (_providertype == AuthenticationProviderTypes.OpenIDConnect) - { -
- -
- -
+
+ +
+
-
- -
- -
+
+
+ +
+
- } +
@@ -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); diff --git a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx index 6c8ffd2a..3467911a 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx @@ -204,8 +204,8 @@ Multiple User Accounts Already Exist With The Email Address Of Your External Login. Please Contact Your Administrator For Further Instructions. - - The External Login Provider Did Not Provide A Valid Email Address For Your Account. Please Contact Your Administrator For Further Instructions. + + The External Login Provider Did Not Provide All Of The Required Information. Please Contact Your Administrator For Further Instructions. An Error Occurred Verifying Your External Login. Please Contact Your Administrator For Further Instructions. diff --git a/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx b/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx index 8bcffb8c..7e6b222d 100644 --- a/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx @@ -147,6 +147,9 @@ Current User Is Not Logged In + + You Must Provide An Email Address For Your User Account + Error Loading User Profile diff --git a/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx index 0b36da94..6baae9c1 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx @@ -247,7 +247,7 @@ Domain Filter: - The name of the email address claim provided by the identity provider + Optionally specify the type name of the email address claim provided by the identity provider. The typical value is 'email'. Email Claim: @@ -274,7 +274,7 @@ Use PKCE? - The external login provider name which will be displayed on the login page + Specify a friendly name for the external login provider which will be displayed on the Login page Provider Name: @@ -373,7 +373,7 @@ Last Login - The name of the unique user identifier claim provided by the identity provider + Specify the type name of the unique user identifier claim provided by the identity provider. The default value is 'sub'. Identifier Claim: @@ -385,13 +385,13 @@ Parameters: - 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. + 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. Role Claim: - 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'. + 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'. User Profile Claims: @@ -460,6 +460,12 @@ Review Claims? - 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. + 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. + + + Optionally specify the type name of the user's name claim provided by the identity provider. The typical value is 'name'. + + + Name Claim: \ No newline at end of file diff --git a/Oqtane.Client/Resources/SharedResources.resx b/Oqtane.Client/Resources/SharedResources.resx index d5355423..b00c7ce9 100644 --- a/Oqtane.Client/Resources/SharedResources.resx +++ b/Oqtane.Client/Resources/SharedResources.resx @@ -435,4 +435,7 @@ Uninstall + + Test + \ No newline at end of file diff --git a/Oqtane.Client/Themes/Controls/Theme/LoginBase.cs b/Oqtane.Client/Themes/Controls/Theme/LoginBase.cs index b3de5229..3279a94e 100644 --- a/Oqtane.Client/Themes/Controls/Theme/LoginBase.cs +++ b/Oqtane.Client/Themes/Controls/Theme/LoginBase.cs @@ -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() diff --git a/Oqtane.Client/Themes/Controls/Theme/UserProfile.razor b/Oqtane.Client/Themes/Controls/Theme/UserProfile.razor index 36a5e0c1..850e28c3 100644 --- a/Oqtane.Client/Themes/Controls/Theme/UserProfile.razor +++ b/Oqtane.Client/Themes/Controls/Theme/UserProfile.razor @@ -1,4 +1,5 @@ @namespace Oqtane.Themes.Controls +@using System.Net @inherits ThemeControlBase @inject IStringLocalizer 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)); } } diff --git a/Oqtane.Client/UI/ThemeBuilder.razor b/Oqtane.Client/UI/ThemeBuilder.razor index 920e362e..f906494b 100644 --- a/Oqtane.Client/UI/ThemeBuilder.razor +++ b/Oqtane.Client/UI/ThemeBuilder.razor @@ -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(" 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 ValidateUser(string email, string id, string claims, HttpContext httpContext, ClaimsPrincipal claimsPrincipal) + private static async Task ValidateUser(string id, string name, string email, string claims, HttpContext httpContext, ClaimsPrincipal claimsPrincipal) { var _logger = httpContext.RequestServices.GetRequiredService(); 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(); 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(); - 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(); - 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(); + 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(); + 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; diff --git a/Oqtane.Server/Managers/UserManager.cs b/Oqtane.Server/Managers/UserManager.cs index dc9c596d..4d273be7 100644 --- a/Oqtane.Server/Managers/UserManager.cs +++ b/Oqtane.Server/Managers/UserManager.cs @@ -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); } } } diff --git a/Oqtane.Server/Pages/External.cshtml.cs b/Oqtane.Server/Pages/External.cshtml.cs index 49c728f0..25a9be2c 100644 --- a/Oqtane.Server/Pages/External.cshtml.cs +++ b/Oqtane.Server/Pages/External.cshtml.cs @@ -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 { diff --git a/Oqtane.Shared/Shared/ExternalLoginStatus.cs b/Oqtane.Shared/Shared/ExternalLoginStatus.cs index 78bdb678..63cd0094 100644 --- a/Oqtane.Shared/Shared/ExternalLoginStatus.cs +++ b/Oqtane.Shared/Shared/ExternalLoginStatus.cs @@ -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";