KeyPressed(e))">
- @if (_allowexternallogin)
- {
-
-
- }
- @if (_allowsitelogin)
- {
-
-
-
-
-
-
-
-
-
-
-
- @if (!_alwaysremember)
+
}
@code {
- private bool _allowsitelogin = true;
+ private string _action = "Login";
private bool _allowexternallogin = false;
+ private bool _allowsitelogin = true;
+ private bool _allowloginlink = false;
private bool _allowpasskeys = false;
+
private ElementReference login;
private bool validated = false;
- private bool twofactor = false;
private string _username = string.Empty;
private ElementReference username;
private string _password = string.Empty;
private string _passwordtype = "password";
private string _togglepassword = string.Empty;
+ private ElementReference password;
private bool _remember = false;
private bool _alwaysremember = false;
- private string _code = string.Empty;
private string _registerurl = string.Empty;
+ private string _email = string.Empty;
+ private string _code = string.Empty;
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Anonymous;
public override bool? Prerender => true;
@@ -116,6 +156,7 @@ else
{
_allowexternallogin = (SettingService.GetSetting(PageState.Site.Settings, "ExternalLogin:ProviderType", "") != "") ? true : false;
_allowsitelogin = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AllowSiteLogin", "true"));
+ _allowloginlink = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:LoginLink", "false"));
_allowpasskeys = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:Passkeys", "false"));
_alwaysremember = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AlwaysRemember", "false"));
@@ -186,6 +227,45 @@ else
}
}
+ private async Task KeyPressed(KeyboardEventArgs e)
+ {
+ if (e.Code == "Enter" || e.Code == "NumpadEnter")
+ {
+ switch (_action)
+ {
+ case "Login":
+ await Login();
+ break;
+ }
+ }
+ }
+
+ private void SetAction(string action)
+ {
+ _action = action;
+ ClearModuleMessage();
+ StateHasChanged();
+ }
+
+ private void ExternalLogin()
+ {
+ NavigationManager.NavigateTo(Utilities.TenantUrl(PageState.Alias, "/pages/external?returnurl=" + WebUtility.UrlEncode(PageState.ReturnUrl)), true);
+ }
+
+ private void TogglePassword()
+ {
+ if (_passwordtype == "password")
+ {
+ _passwordtype = "text";
+ _togglepassword = SharedLocalizer["HidePassword"];
+ }
+ else
+ {
+ _passwordtype = "password";
+ _togglepassword = SharedLocalizer["ShowPassword"];
+ }
+ }
+
private async Task Login()
{
try
@@ -197,7 +277,7 @@ else
var hybrid = (PageState.Runtime == Shared.Runtime.Hybrid);
var user = new User { SiteId = PageState.Site.SiteId, Username = _username, Password = _password, LastIPAddress = SiteState.RemoteIPAddress};
- if (!twofactor)
+ if (_action == "Login")
{
_remember = _alwaysremember || _remember;
user = await UserService.LoginUserAsync(user, hybrid, _remember);
@@ -233,13 +313,13 @@ else
{
if (SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:TwoFactor", "false") == "required" || (user != null && user.TwoFactorRequired))
{
- twofactor = true;
+ _action = "TwoFactor";
validated = false;
AddModuleMessage(Localizer["Message.TwoFactor"], MessageType.Info);
}
else
{
- if (!twofactor)
+ if (_action != "TwoFactor")
{
await logger.LogInformation(LogFunction.Security, "Login Failed For Username {Username}", _username);
AddModuleMessage(Localizer["Error.Login.Fail"], MessageType.Error);
@@ -264,23 +344,32 @@ else
}
}
- private void Cancel()
+ private void CancelLogin()
{
NavigationManager.NavigateTo(PageState.ReturnUrl);
}
- private async Task Forgot()
+ private async Task PasskeyLogin()
+ {
+ // post back to the Passkey page so that the cookies are set correctly
+ var interop = new Interop(JSRuntime);
+ var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, operation = "request", returnurl = NavigateUrl() };
+ string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/");
+ await interop.SubmitForm(url, fields);
+ }
+
+ private async Task ForgotPassword()
{
try
{
- if (_username != string.Empty)
+ if (!string.IsNullOrEmpty(_username))
{
- var user = await UserService.GetUserAsync(_username, PageState.Site.SiteId);
+ var user = new User { Username = _username };
+ user = await UserService.ForgotPasswordAsync(user);
if (user != null)
{
- await UserService.ForgotPasswordAsync(user);
await logger.LogInformation(LogFunction.Security, "Password Reset Notification Sent For Username {Username}", _username);
- AddModuleMessage(Localizer["Message.ForgotUser"], MessageType.Info);
+ AddModuleMessage(Localizer["Message.ForgotPassword"], MessageType.Info);
}
else
{
@@ -289,10 +378,8 @@ else
}
else
{
- AddModuleMessage(Localizer["Message.ForgotPassword"], MessageType.Info);
+ AddModuleMessage(Localizer["Message.Required.UserInfo"], MessageType.Warning);
}
-
- StateHasChanged();
}
catch (Exception ex)
{
@@ -301,51 +388,66 @@ else
}
}
- private void Reset()
+ private async Task ForgotUsername()
{
- twofactor = false;
- _username = "";
- _password = "";
- ClearModuleMessage();
- StateHasChanged();
- }
-
- private async Task KeyPressed(KeyboardEventArgs e)
- {
- if (e.Code == "Enter" || e.Code == "NumpadEnter")
+ try
{
- await Login();
+ if (!string.IsNullOrEmpty(_email))
+ {
+ var user = new User { Email = _email };
+ user = await UserService.ForgotUsernameAsync(user);
+ if (user != null)
+ {
+ AddModuleMessage(Localizer["Message.ForgotUsername"], MessageType.Info);
+ await logger.LogInformation(LogFunction.Security, "Username Reminder Notification Sent For Email {Email}", _email);
+ }
+ else
+ {
+ AddModuleMessage(Localizer["Message.UserDoesNotExist"], MessageType.Warning);
+ }
+ }
+ else
+ {
+ AddModuleMessage(Localizer["Message.Required.UserInfo"], MessageType.Warning);
+ }
+ }
+ catch (Exception ex)
+ {
+ await logger.LogError(ex, "Error Sending Username Reminder {Error}", ex.Message);
+ AddModuleMessage(Localizer["Error.ForgotUsername"], MessageType.Error);
}
}
- private void TogglePassword()
+ private async Task LoginLink()
{
- if (_passwordtype == "password")
+ try
{
- _passwordtype = "text";
- _togglepassword = SharedLocalizer["HidePassword"];
+ if (!string.IsNullOrEmpty(_email))
+ {
+ var user = new User { Email = _email };
+ user = await UserService.SendLoginLinkAsync(user);
+ if (user != null)
+ {
+ AddModuleMessage(Localizer["Message.SendLoginLink"], MessageType.Info);
+ await logger.LogInformation(LogFunction.Security, "Login Link Sent To Email {Email}", _email);
+ }
+ else
+ {
+ AddModuleMessage(Localizer["Message.UserDoesNotExist"], MessageType.Warning);
+ }
+ }
+ else
+ {
+ AddModuleMessage(Localizer["Message.Required.UserInfo"], MessageType.Warning);
+ }
}
- else
+ catch (Exception ex)
{
- _passwordtype = "password";
- _togglepassword = SharedLocalizer["ShowPassword"];
+ await logger.LogError(ex, "Error Sending Login Link {Error}", ex.Message);
+ AddModuleMessage(Localizer["Error.SendLoginLink"], MessageType.Error);
}
}
- private void ExternalLogin()
- {
- NavigationManager.NavigateTo(Utilities.TenantUrl(PageState.Alias, "/pages/external?returnurl=" + WebUtility.UrlEncode(PageState.ReturnUrl)), true);
- }
-
- private async Task Passkey()
- {
- // post back to the Passkey page so that the cookies are set correctly
- var interop = new Interop(JSRuntime);
- var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, operation = "request", returnurl = NavigateUrl() };
- string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/");
- await interop.SubmitForm(url, fields);
- }
-
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && PageState.QueryString.ContainsKey("options"))
@@ -377,11 +479,21 @@ else
return;
}
- if (firstRender && PageState.User == null && _allowsitelogin)
+ if (firstRender && PageState.User == null && _allowsitelogin && _action == "Login")
{
- if (!string.IsNullOrEmpty(username.Id)) // ensure username is visible in UI
+ if (string.IsNullOrEmpty(_username))
{
- await username.FocusAsync();
+ if (!string.IsNullOrEmpty(username.Id)) // ensure username is visible in UI
+ {
+ await username.FocusAsync();
+ }
+ }
+ else
+ {
+ if (!string.IsNullOrEmpty(password.Id)) // ensure password is visible in UI
+ {
+ await password.FocusAsync();
+ }
}
}
diff --git a/Oqtane.Client/Modules/Admin/Users/Index.razor b/Oqtane.Client/Modules/Admin/Users/Index.razor
index edb519c2..cad17d8b 100644
--- a/Oqtane.Client/Modules/Admin/Users/Index.razor
+++ b/Oqtane.Client/Modules/Admin/Users/Index.razor
@@ -98,6 +98,15 @@ else
+
@@ -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";
}
}