From ec2afd5f030bab3b4818d0943e642470079e90ef Mon Sep 17 00:00:00 2001 From: sbwalker Date: Sun, 14 Dec 2025 15:13:53 -0500 Subject: [PATCH] added support for Forgot Username and Use Login Link --- Oqtane.Client/Modules/Admin/Login/Index.razor | 316 ++++++++++++------ Oqtane.Client/Modules/Admin/Users/Index.razor | 12 + .../Resources/Modules/Admin/Login/Index.resx | 47 ++- .../Resources/Modules/Admin/Users/Index.resx | 6 + Oqtane.Client/Services/UserService.cs | 30 +- Oqtane.Server/Controllers/UserController.cs | 27 +- Oqtane.Server/Managers/UserManager.cs | 99 +++++- Oqtane.Server/Pages/LoginLink.cshtml | 3 + Oqtane.Server/Pages/LoginLink.cshtml.cs | 69 ++++ .../Resources/Managers/UserManager.resx | 14 +- Oqtane.Shared/Shared/ExternalLoginStatus.cs | 1 + 11 files changed, 500 insertions(+), 124 deletions(-) create mode 100644 Oqtane.Server/Pages/LoginLink.cshtml create mode 100644 Oqtane.Server/Pages/LoginLink.cshtml.cs diff --git a/Oqtane.Client/Modules/Admin/Login/Index.razor b/Oqtane.Client/Modules/Admin/Login/Index.razor index 83d49fd9..460aceab 100644 --- a/Oqtane.Client/Modules/Admin/Login/Index.razor +++ b/Oqtane.Client/Modules/Admin/Login/Index.razor @@ -14,93 +14,133 @@ } else { - @if (!twofactor) - { -
-
+
+ +
+ +
+
@@ -547,6 +556,7 @@ else private string _registerurl; private string _profileurl; private string _requireconfirmedemail; + private string _loginlink; private string _passkeys; private string _twofactor; private string _cookiename; @@ -625,6 +635,7 @@ else _registerurl = SettingService.GetSetting(settings, "LoginOptions:RegisterUrl", ""); _profileurl = SettingService.GetSetting(settings, "LoginOptions:ProfileUrl", ""); _requireconfirmedemail = SettingService.GetSetting(settings, "LoginOptions:RequireConfirmedEmail", "true"); + _loginlink = SettingService.GetSetting(settings, "LoginOptions:LoginLink", "false"); _passkeys = SettingService.GetSetting(settings, "LoginOptions:Passkeys", "false"); _twofactor = SettingService.GetSetting(settings, "LoginOptions:TwoFactor", "false"); _cookiename = SettingService.GetSetting(settings, "LoginOptions:CookieName", ".AspNetCore.Identity.Application"); @@ -764,6 +775,7 @@ else settings = SettingService.SetSetting(settings, "LoginOptions:RegisterUrl", _registerurl, false); settings = SettingService.SetSetting(settings, "LoginOptions:ProfileUrl", _profileurl, false); settings = SettingService.SetSetting(settings, "LoginOptions:RequireConfirmedEmail", _requireconfirmedemail, false); + settings = SettingService.SetSetting(settings, "LoginOptions:LoginLink", _loginlink, false); settings = SettingService.SetSetting(settings, "LoginOptions:Passkeys", _passkeys, false); settings = SettingService.SetSetting(settings, "LoginOptions:TwoFactor", _twofactor, false); settings = SettingService.SetSetting(settings, "LoginOptions:CookieName", _cookiename, true); diff --git a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx index 80f587e9..af2a52ba 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx @@ -120,6 +120,12 @@ Forgot Password? + + Forgot Username? + + + Use Login Link + User Account Email Address Verified Successfully. You Can Now Login With Your Username And Password. @@ -142,16 +148,19 @@ You Are Already Signed In - Please Enter The Username Related To Your Account And Then Select The Forgot Password Option Again - - Please Check The Email Address Associated To Your User Account For A Password Reset Notification + + Please Check Your Email For A Username Reminder Notification + + + A Login Link Has Been Sent To Your Email Address. The Link Is Only Valid For A Limited Amount Of Time. + - User Does Not Exist + User Does Not Exist For Criteria Specified - Please Enter The Secure Verification Code Which Was Sent To You By Email. + Please enter the secure verification code which was sent to you by email Verification Code @@ -166,7 +175,7 @@ A Secure Verification Code Has Been Sent To Your Email Address. Please Enter The Code That You Received. If You Do Not Receive The Code Or You Have Lost Access To Your Email, Please Contact Your Administrator. - Please Enter The Password Related To Your Account. Remember That Passwords Are Case Sensitive. If You Attempt Unsuccessfully To Log In To Your Account Multiple Times, You Will Be Locked Out For A Period Of Time. + Please enter the password related to your account. Remember that passwords are sase sensitive. If you attempt to login to your account multiple times unsuccessfully, you will be locked out for a period of time. Password @@ -175,13 +184,13 @@ Password: - Specify If You Would Like To Be Signed Back In Automatically The Next Time You Visit This Site + Specify if you would like to be signed back in automatically the next time you visit this site - Remember Me? + Stay Signed In? - Please Enter The Username Related To Your Account + Please enter the username related to your account Username @@ -201,7 +210,13 @@ Error Resetting Password - + + Error Sending Username Reminder + + + Error Sending Login Link + + Multiple User Accounts Already Exist With The Email Address Of Your External Login. Please Contact Your Administrator For Further Instructions. @@ -228,6 +243,9 @@ The Review Claims Option Was Enabled In External Login Settings. Please Visit The Event Log To View The Claims Returned By The Provider. + + Login Links Are Time Sensitive. Please Request Another Login Link To Complete The Login Process. + Register as new user? @@ -237,4 +255,13 @@ Passkey Login Was Not Successful + + Please enter the email address related to your account + + + Email Address + + + Email: + \ No newline at end of file diff --git a/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx index 6e7fb3e0..8d1bd113 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx @@ -567,4 +567,10 @@ Do you want to allow users to login using passkeys (ie. passwordless authentication using WebAuthn/FIDO2)? + + Allow Login Link? + + + Do you want to allow users to login using a time sensitive link sent by email? + \ No newline at end of file diff --git a/Oqtane.Client/Services/UserService.cs b/Oqtane.Client/Services/UserService.cs index 12685add..78d8f0af 100644 --- a/Oqtane.Client/Services/UserService.cs +++ b/Oqtane.Client/Services/UserService.cs @@ -101,7 +101,14 @@ namespace Oqtane.Services /// /// /// - Task ForgotPasswordAsync(User user); + Task ForgotPasswordAsync(User user); + + /// + /// Trigger a forgot-username e-mail for this . + /// + /// + /// + Task ForgotUsernameAsync(User user); /// /// Reset the password of this @@ -211,6 +218,13 @@ namespace Oqtane.Services /// /// Task DeleteLoginAsync(int userId, string provider, string key); + + /// + /// Send a login link + /// + /// + /// + Task SendLoginLinkAsync(User user); } [PrivateApi("Don't show in the documentation, as everything should use the Interface")] @@ -275,9 +289,14 @@ namespace Oqtane.Services return await PostJsonAsync($"{Apiurl}/verify?token={token}", user); } - public async Task ForgotPasswordAsync(User user) + public async Task ForgotPasswordAsync(User user) { - await PostJsonAsync($"{Apiurl}/forgot", user); + return await PostJsonAsync($"{Apiurl}/forgot", user); + } + + public async Task ForgotUsernameAsync(User user) + { + return await PostJsonAsync($"{Apiurl}/forgotusername", user); } public async Task ResetPasswordAsync(User user, string token) @@ -366,5 +385,10 @@ namespace Oqtane.Services { await DeleteAsync($"{Apiurl}/login?id={userId}&provider={provider}&key={key}"); } + + public async Task SendLoginLinkAsync(User user) + { + return await PostJsonAsync($"{Apiurl}/loginlink", user); + } } } diff --git a/Oqtane.Server/Controllers/UserController.cs b/Oqtane.Server/Controllers/UserController.cs index 018e2695..3d28e86e 100644 --- a/Oqtane.Server/Controllers/UserController.cs +++ b/Oqtane.Server/Controllers/UserController.cs @@ -296,12 +296,24 @@ namespace Oqtane.Controllers // POST api//forgot [HttpPost("forgot")] - public async Task Forgot([FromBody] User user) + public async Task Forgot([FromBody] User user) { if (ModelState.IsValid) { - await _userManager.ForgotPassword(user); + return await _userManager.ForgotPassword(user); } + return null; + } + + // POST api//forgotusername + [HttpPost("forgotusername")] + public async Task ForgotUsername([FromBody] User user) + { + if (ModelState.IsValid) + { + return await _userManager.ForgotUsername(user); + } + return null; } // POST api//reset @@ -559,5 +571,16 @@ namespace Oqtane.Controllers HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; } } + + // POST api//loginlink + [HttpPost("loginlink")] + public async Task SendLoginLink([FromBody] User user) + { + if (ModelState.IsValid) + { + return await _userManager.SendLoginLink(user); + } + return null; + } } } diff --git a/Oqtane.Server/Managers/UserManager.cs b/Oqtane.Server/Managers/UserManager.cs index e6f2661d..901c8ee8 100644 --- a/Oqtane.Server/Managers/UserManager.cs +++ b/Oqtane.Server/Managers/UserManager.cs @@ -4,10 +4,8 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; -using System.Security.Policy; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Localization; using Oqtane.Enums; @@ -30,7 +28,8 @@ namespace Oqtane.Managers Task LoginUser(User user, bool setCookie, bool isPersistent); Task LogoutUserEverywhere(User user); Task VerifyEmail(User user, string token); - Task ForgotPassword(User user); + Task ForgotPassword(User user); + Task ForgotUsername(User user); Task ResetPassword(User user, string token); User VerifyTwoFactor(User user, string token); Task ValidateUser(string username, string email, string password); @@ -42,6 +41,7 @@ namespace Oqtane.Managers Task> GetLogins(int userId, int siteId); Task AddLogin(User user, string token, string type, string key, string name); Task DeleteLogin(int userId, string provider, string key); + Task SendLoginLink(User user); } public class UserManager : IUserManager @@ -519,14 +519,16 @@ namespace Oqtane.Managers } return user; } - public async Task ForgotPassword(User user) + + public async Task ForgotPassword(User user) { IdentityUser identityuser = await _identityUserManager.FindByNameAsync(user.Username); if (identityuser != null) { - var alias = _tenantManager.GetAlias(); - user = _users.GetUser(user.Username); string token = await _identityUserManager.GeneratePasswordResetTokenAsync(identityuser); + + var alias = _tenantManager.GetAlias(); + user = GetUser(user.Username, alias.SiteId); string url = alias.Protocol + alias.Name + "/reset?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); string siteName = _sites.GetSite(alias.SiteId).Name; string subject = _localizer["ForgotPasswordEmailSubject"]; @@ -537,11 +539,51 @@ namespace Oqtane.Managers body = body.Replace("[SiteName]", siteName); var notification = new Notification(_tenantManager.GetAlias().SiteId, user, subject, body); _notifications.AddNotification(notification); + _logger.Log(LogLevel.Information, this, LogFunction.Security, "Password Reset Notification Sent For {Username}", user.Username); + return new User { UserId = user.UserId, Username = user.Username, Email = user.Email }; // minimal object } else { _logger.Log(LogLevel.Error, this, LogFunction.Security, "Password Reset Notification Failed For {Username}", user.Username); + return null; + } + } + + public async Task ForgotUsername(User user) + { + try + { + IdentityUser identityuser = await _identityUserManager.FindByEmailAsync(user.Email); + if (identityuser != null) + { + var alias = _tenantManager.GetAlias(); + user = GetUser(identityuser.UserName, alias.SiteId); + string url = alias.Protocol + alias.Name + "/login?name=" + user.Username; + string siteName = _sites.GetSite(alias.SiteId).Name; + string subject = _localizer["ForgotUsernameEmailSubject"]; + subject = subject.Replace("[SiteName]", siteName); + string body = _localizer["ForgotUsernameEmailBody"].Value; + body = body.Replace("[UserDisplayName]", user.DisplayName); + body = body.Replace("[URL]", url); + body = body.Replace("[SiteName]", siteName); + var notification = new Notification(_tenantManager.GetAlias().SiteId, user, subject, body); + _notifications.AddNotification(notification); + + _logger.Log(LogLevel.Information, this, LogFunction.Security, "Forgot Username Notification Sent For {Email}", user.Email); + return new User { UserId = user.UserId, Username = user.Username, Email = user.Email }; // minimal object + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Forgot Username Notification Failed For {Email}", user.Email); + return null; + } + } + catch + { + // email may not be unique + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Forgot Username Notification Failed For {Email}", user.Email); + return null; } } @@ -588,6 +630,7 @@ namespace Oqtane.Managers } return user; } + public async Task ValidateUser(string username, string email, string password) { var validateResult = new UserValidateResult { Succeeded = true }; @@ -914,5 +957,49 @@ namespace Oqtane.Managers } } } + + public async Task SendLoginLink(User user) + { + try + { + IdentityUser identityuser = await _identityUserManager.FindByEmailAsync(user.Email); + if (identityuser != null) + { + var token = await _identityUserManager.GenerateTwoFactorTokenAsync(identityuser, "Email"); + + var alias = _tenantManager.GetAlias(); + user = GetUser(identityuser.UserName, alias.SiteId); + user.TwoFactorCode = token; + user.TwoFactorExpiry = DateTime.UtcNow.AddMinutes(10); + _users.UpdateUser(user); + + string url = alias.Protocol + alias.Name + "/pages/loginlink?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); + string siteName = _sites.GetSite(alias.SiteId).Name; + string subject = _localizer["LoginLinkEmailSubject"]; + subject = subject.Replace("[SiteName]", siteName); + string body = _localizer["LoginLinkEmailBody"].Value; + body = body.Replace("[UserDisplayName]", user.DisplayName); + body = body.Replace("[URL]", url); + body = body.Replace("[SiteName]", siteName); + var notification = new Notification(_tenantManager.GetAlias().SiteId, user, subject, body); + _notifications.AddNotification(notification); + + _logger.Log(LogLevel.Information, this, LogFunction.Security, "Login Link Notification Sent To {Email}", user.Email); + return new User { UserId = user.UserId, Username = user.Username, Email = user.Email }; // minimal object + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Login Link Notification Failed For {Email}", user.Email); + return null; + } + } + catch + { + // email may not be unique + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Login Link Notification Failed For {Email}", user.Email); + return null; + } + } + } } diff --git a/Oqtane.Server/Pages/LoginLink.cshtml b/Oqtane.Server/Pages/LoginLink.cshtml new file mode 100644 index 00000000..c069722c --- /dev/null +++ b/Oqtane.Server/Pages/LoginLink.cshtml @@ -0,0 +1,3 @@ +@page "/pages/loginlink" +@namespace Oqtane.Pages +@model Oqtane.Pages.LoginLinkModel diff --git a/Oqtane.Server/Pages/LoginLink.cshtml.cs b/Oqtane.Server/Pages/LoginLink.cshtml.cs new file mode 100644 index 00000000..01748f38 --- /dev/null +++ b/Oqtane.Server/Pages/LoginLink.cshtml.cs @@ -0,0 +1,69 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Oqtane.Enums; +using Oqtane.Extensions; +using Oqtane.Infrastructure; +using Oqtane.Managers; +using Oqtane.Shared; + +namespace Oqtane.Pages +{ + [AllowAnonymous] + public class LoginLinkModel : PageModel + { + private readonly UserManager _identityUserManager; + private readonly SignInManager _identitySignInManager; + private readonly IUserManager _userManager; + private readonly ILogManager _logger; + + public LoginLinkModel(UserManager identityUserManager, SignInManager identitySignInManager, IUserManager userManager, ILogManager logger) + { + _identityUserManager = identityUserManager; + _identitySignInManager = identitySignInManager; + _userManager = userManager; + _logger = logger; + } + + public async Task OnGetAsync(string name, string token) + { + var returnurl = "/login"; + + if (bool.Parse(HttpContext.GetSiteSettings().GetValue("LoginOptions:LoginLink", "false")) && + !User.Identity.IsAuthenticated && !string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(token)) + { + var validuser = false; + + IdentityUser identityuser = await _identityUserManager.FindByNameAsync(name); + if (identityuser != null) + { + var user = _userManager.GetUser(identityuser.UserName, HttpContext.GetAlias().SiteId); + if (user != null && user.TwoFactorCode == token && DateTime.UtcNow < user.TwoFactorExpiry) + { + await _identitySignInManager.SignInAsync(identityuser, false); + _logger.Log(LogLevel.Information, this, LogFunction.Security, "Login Link Successful For User {Username}", name); + validuser = true; + returnurl = "/"; + } + } + + if (!validuser) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Login Link Failed For User {Username}", name); + returnurl += $"?status={ExternalLoginStatus.LoginLinkFailed}"; + } + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Login Link Attempt For User {Username}", name); + returnurl = "/"; + } + + return LocalRedirect(Url.Content("~" + returnurl)); + } + } +} diff --git a/Oqtane.Server/Resources/Managers/UserManager.resx b/Oqtane.Server/Resources/Managers/UserManager.resx index ecc38fb0..64c55528 100644 --- a/Oqtane.Server/Resources/Managers/UserManager.resx +++ b/Oqtane.Server/Resources/Managers/UserManager.resx @@ -121,7 +121,19 @@ Dear [UserDisplayName]<br><br>You recently requested to reset your password. Please use the link below to complete the process: <b><a href="[URL]"><br><br>Click here to Reset Password</a></b><br><br>Please note that the link is only valid for 24 hours so if you are unable to take action within that time period, you should initiate another password reset on the site.<br><br>If you did not request to reset your password you can safely ignore this message.<br><br>Thank You!<br>[SiteName] Team - Password Reset Notification Sent For [SiteName] + Password Reset Notification For [SiteName] + + + Dear [UserDisplayName]<br><br>You recently requested a username reminder. Please use the link below to complete the process: <b><a href="[URL]"><br><br>Click here to Login</a></b><br><br>If you did not request a username reminder you can safely ignore this message.<br><br>Thank You!<br>[SiteName] Team + + + Forgotten Username Reminder For [SiteName] + + + Dear [UserDisplayName]<br><br>You recently requested a login link. Please use the link below to complete the process: <b><a href="[URL]"><br><br>Click here to Login</a></b><br><br>Please note that the link is only valid for 10 minutes so if you are unable to take action within that time period, you should initiate another login link request on the site.<br><br>If you did not request a login link you can safely ignore this message.<br><br>Thank You!<br>[SiteName] Team + + + Login Link Notification For [SiteName] Dear [UserDisplayName],<br><br>A user account has been successfully created for you with the username <b>[Username]</b>. Please <b><a href="[URL]">click here to login</a></b>. If you do not know your password, use the forgot password option on the login page to reset your account.<br><br>Thank You!<br>[SiteName] Team diff --git a/Oqtane.Shared/Shared/ExternalLoginStatus.cs b/Oqtane.Shared/Shared/ExternalLoginStatus.cs index 63cd0094..8423d799 100644 --- a/Oqtane.Shared/Shared/ExternalLoginStatus.cs +++ b/Oqtane.Shared/Shared/ExternalLoginStatus.cs @@ -10,5 +10,6 @@ namespace Oqtane.Shared { public const string AccessDenied = "AccessDenied"; public const string RemoteFailure = "RemoteFailure"; public const string ReviewClaims = "ReviewClaims"; + public const string LoginLinkFailed = "LoginLinkFailed"; } }