Merge pull request #5899 from sbwalker/dev

login improvements
This commit is contained in:
Shaun Walker
2025-12-18 16:01:05 -05:00
committed by GitHub
11 changed files with 62 additions and 52 deletions

View File

@@ -127,6 +127,7 @@ else
private bool _allowsitelogin = true; private bool _allowsitelogin = true;
private bool _allowloginlink = false; private bool _allowloginlink = false;
private bool _allowpasskeys = false; private bool _allowpasskeys = false;
private string _returnurl = string.Empty;
private ElementReference login; private ElementReference login;
private bool validated = false; private bool validated = false;
@@ -169,6 +170,9 @@ else
_registerurl = NavigateUrl("register"); _registerurl = NavigateUrl("register");
} }
// PageState.ReturnUrl is not specified if user navigated directly to login page
_returnurl = (!string.IsNullOrEmpty(PageState.ReturnUrl)) ? PageState.ReturnUrl : PageState.Alias.Path;
_togglepassword = SharedLocalizer["ShowPassword"]; _togglepassword = SharedLocalizer["ShowPassword"];
if (PageState.QueryString.ContainsKey("name")) if (PageState.QueryString.ContainsKey("name"))
@@ -216,7 +220,7 @@ else
{ {
if (PageState.QueryString.ContainsKey("status")) if (PageState.QueryString.ContainsKey("status"))
{ {
AddModuleMessage(Localizer["ExternalLoginStatus." + PageState.QueryString["status"]], MessageType.Info); AddModuleMessage(Localizer["ExternalLoginStatus." + PageState.QueryString["status"]], MessageType.Warning);
} }
} }
} }
@@ -252,7 +256,7 @@ else
private void ExternalLogin() private void ExternalLogin()
{ {
NavigationManager.NavigateTo(Utilities.TenantUrl(PageState.Alias, "/pages/external?returnurl=" + WebUtility.UrlEncode(PageState.ReturnUrl)), true); NavigationManager.NavigateTo(Utilities.TenantUrl(PageState.Alias, "/pages/external?returnurl=" + WebUtility.UrlEncode(_returnurl)), true);
} }
private void TogglePassword() private void TogglePassword()
@@ -294,20 +298,17 @@ else
{ {
await logger.LogInformation(LogFunction.Security, "Login Successful For {Username} From IP Address {IPAddress}", _username, SiteState.RemoteIPAddress); await logger.LogInformation(LogFunction.Security, "Login Successful For {Username} From IP Address {IPAddress}", _username, SiteState.RemoteIPAddress);
// return url is not specified if user navigated directly to login page
var returnurl = (!string.IsNullOrEmpty(PageState.ReturnUrl)) ? PageState.ReturnUrl : PageState.Alias.Path;
if (hybrid) if (hybrid)
{ {
// hybrid apps utilize an interactive login // hybrid apps utilize an interactive login
var authstateprovider = (IdentityAuthenticationStateProvider)ServiceProvider.GetService(typeof(IdentityAuthenticationStateProvider)); var authstateprovider = (IdentityAuthenticationStateProvider)ServiceProvider.GetService(typeof(IdentityAuthenticationStateProvider));
authstateprovider.NotifyAuthenticationChanged(); authstateprovider.NotifyAuthenticationChanged();
NavigationManager.NavigateTo(NavigateUrl(returnurl, true)); NavigationManager.NavigateTo(NavigateUrl(_returnurl, true));
} }
else else
{ {
// post back to the Login page so that the cookies are set correctly // post back to the Login page so that the cookies are set correctly
var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, username = _username, password = _password, remember = _remember, returnurl = WebUtility.UrlEncode(returnurl) }; var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, username = _username, password = _password, remember = _remember, returnurl = WebUtility.UrlEncode(_returnurl) };
string url = Utilities.TenantUrl(PageState.Alias, "/pages/login/"); string url = Utilities.TenantUrl(PageState.Alias, "/pages/login/");
await interop.SubmitForm(url, fields); await interop.SubmitForm(url, fields);
} }
@@ -349,14 +350,14 @@ else
private void CancelLogin() private void CancelLogin()
{ {
NavigationManager.NavigateTo(PageState.ReturnUrl); NavigationManager.NavigateTo(_returnurl);
} }
private async Task PasskeyLogin() private async Task PasskeyLogin()
{ {
// post back to the Passkey page so that the cookies are set correctly // post back to the Passkey page so that the cookies are set correctly
var interop = new Interop(JSRuntime); var interop = new Interop(JSRuntime);
var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, operation = "request", returnurl = NavigateUrl() }; var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, operation = "request", returnurl = _returnurl };
string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/"); string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/");
await interop.SubmitForm(url, fields); await interop.SubmitForm(url, fields);
} }
@@ -423,7 +424,7 @@ else
{ {
if (!string.IsNullOrEmpty(_email)) if (!string.IsNullOrEmpty(_email))
{ {
if (await UserService.SendLoginLinkAsync(_email)) if (await UserService.SendLoginLinkAsync(_email, _returnurl))
{ {
AddModuleMessage(Localizer["Message.SendLoginLink"], MessageType.Info); AddModuleMessage(Localizer["Message.SendLoginLink"], MessageType.Info);
await logger.LogInformation(LogFunction.Security, "Login Link Sent To Email {Email}", _email); await logger.LogInformation(LogFunction.Security, "Login Link Sent To Email {Email}", _email);
@@ -457,8 +458,7 @@ else
if (!string.IsNullOrEmpty(credential)) if (!string.IsNullOrEmpty(credential))
{ {
// post back to the Passkey page so that the cookies are set correctly // post back to the Passkey page so that the cookies are set correctly
var returnurl = (!string.IsNullOrEmpty(PageState.ReturnUrl)) ? PageState.ReturnUrl : PageState.Alias.Path + "/"; var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, operation = "login", credential = credential, returnurl = _returnurl };
var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, operation = "login", credential = credential, returnurl = returnurl };
string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/"); string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/");
await interop.SubmitForm(url, fields); await interop.SubmitForm(url, fields);
} }
@@ -497,7 +497,7 @@ else
// redirect logged in user to specified page // redirect logged in user to specified page
if (PageState.User != null && !UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin)) if (PageState.User != null && !UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin))
{ {
NavigationManager.NavigateTo(PageState.ReturnUrl); NavigationManager.NavigateTo(_returnurl);
} }
} }
} }

View File

@@ -114,9 +114,9 @@
} }
@if (_allowpasskeys) @if (_allowpasskeys)
{ {
<Section Name="Passkeys" Heading="Passkeys" ResourceKey="Passkeys"> <Section Name="Passkeys" Heading="Passkeys" ResourceKey="Passkeys" Expanded="@((_passkeys.Count > 0).ToString())">
<button type="button" class="btn btn-primary" @onclick="AddPasskey">@SharedLocalizer["Add"]</button> <button type="button" class="btn btn-primary" @onclick="AddPasskey">@SharedLocalizer["Add"]</button>
@if (_passkeys != null && _passkeys.Count > 0) @if (_passkeys.Count > 0)
{ {
<Pager Items="@_passkeys"> <Pager Items="@_passkeys">
<Header> <Header>
@@ -142,15 +142,15 @@
} }
else else
{ {
<div>@Localizer["Message.Passkeys.None"]</div> <div class="mt-2">@Localizer["Message.Passkeys.None"]</div>
} }
</Section> </Section>
<br /> <br />
} }
@if (_allowexternallogin) @if (_allowexternallogin)
{ {
<Section Name="Logins" Heading="Logins" ResourceKey="Logins"> <Section Name="Logins" Heading="Logins" ResourceKey="Logins" Expanded="@((_logins.Count > 0).ToString())">
@if (_logins != null && _logins.Count > 0) @if (_logins.Count > 0)
{ {
<Pager Items="@_logins"> <Pager Items="@_logins">
<Header> <Header>
@@ -165,7 +165,7 @@
} }
else else
{ {
<div>@Localizer["Message.Logins.None"]</div> <div class="mt-2">@Localizer["Message.Logins.None"]</div>
} }
</Section> </Section>
<br /> <br />

View File

@@ -106,8 +106,8 @@
<br /><br /> <br /><br />
@if (_allowpasskeys) @if (_allowpasskeys)
{ {
<Section Name="Passkeys" Heading="Passkeys" ResourceKey="Passkeys"> <Section Name="Passkeys" Heading="Passkeys" ResourceKey="Passkeys" Expanded="@((_passkeys.Count > 0).ToString())">
@if (_passkeys != null && _passkeys.Count > 0) @if (_passkeys.Count > 0)
{ {
<Pager Items="@_passkeys"> <Pager Items="@_passkeys">
<Header> <Header>
@@ -122,15 +122,15 @@
} }
else else
{ {
<div>@Localizer["Message.Passkeys.None"]</div> <div class="mt-2">@Localizer["Message.Passkeys.None"]</div>
} }
</Section> </Section>
<br /> <br />
} }
@if (_allowexternallogin) @if (_allowexternallogin)
{ {
<Section Name="Logins" Heading="Logins" ResourceKey="Logins"> <Section Name="Logins" Heading="Logins" ResourceKey="Logins" Expanded="@((_logins.Count > 0).ToString())">
@if (_logins != null && _logins.Count > 0) @if (_logins.Count > 0)
{ {
<Pager Items="@_logins"> <Pager Items="@_logins">
<Header> <Header>
@@ -145,7 +145,7 @@
} }
else else
{ {
<div>@Localizer["Message.Logins.None"]</div> <div class="mt-2">@Localizer["Message.Logins.None"]</div>
} }
</Section> </Section>
<br /> <br />

View File

@@ -246,6 +246,9 @@
<data name="ExternalLoginStatus.LoginLinkFailed" xml:space="preserve"> <data name="ExternalLoginStatus.LoginLinkFailed" xml:space="preserve">
<value>Login Links Are Time Sensitive. Please Request Another Login Link To Complete The Login Process.</value> <value>Login Links Are Time Sensitive. Please Request Another Login Link To Complete The Login Process.</value>
</data> </data>
<data name="ExternalLoginStatus.PasskeyFailed" xml:space="preserve">
<value>Passkey Login Was Unsuccessful. Please Ensure You Selected The Correct Passkey For This Site.</value>
</data>
<data name="Register" xml:space="preserve"> <data name="Register" xml:space="preserve">
<value>Register as new user?</value> <value>Register as new user?</value>
</data> </data>

View File

@@ -224,7 +224,7 @@ namespace Oqtane.Services
/// </summary> /// </summary>
/// <param name="email"></param> /// <param name="email"></param>
/// <returns></returns> /// <returns></returns>
Task<bool> SendLoginLinkAsync(string email); Task<bool> SendLoginLinkAsync(string email, string returnurl);
} }
[PrivateApi("Don't show in the documentation, as everything should use the Interface")] [PrivateApi("Don't show in the documentation, as everything should use the Interface")]
@@ -386,9 +386,9 @@ namespace Oqtane.Services
await DeleteAsync($"{Apiurl}/login?id={userId}&provider={provider}&key={key}"); await DeleteAsync($"{Apiurl}/login?id={userId}&provider={provider}&key={key}");
} }
public async Task<bool> SendLoginLinkAsync(string email) public async Task<bool> SendLoginLinkAsync(string email, string returnurl)
{ {
return await GetJsonAsync<bool>($"{Apiurl}/loginlink/{WebUtility.UrlEncode(email)}"); return await GetJsonAsync<bool>($"{Apiurl}/loginlink/{WebUtility.UrlEncode(email)}/{WebUtility.UrlEncode(returnurl)}");
} }
} }
} }

View File

@@ -563,11 +563,11 @@ namespace Oqtane.Controllers
} }
} }
// GET api/<controller>/loginlink/x // GET api/<controller>/loginlink/x/y
[HttpGet("loginlink/{email}")] [HttpGet("loginlink/{email}/{returnurl}")]
public async Task<bool> SendLoginLink(string email) public async Task<bool> SendLoginLink(string email, string returnurl)
{ {
return await _userManager.SendLoginLink(email); return await _userManager.SendLoginLink(email, returnurl);
} }
} }
} }

View File

@@ -41,7 +41,7 @@ namespace Oqtane.Managers
Task<List<UserLogin>> GetLogins(int userId, int siteId); Task<List<UserLogin>> GetLogins(int userId, int siteId);
Task<User> AddLogin(User user, string token, string type, string key, string name); Task<User> AddLogin(User user, string token, string type, string key, string name);
Task DeleteLogin(int userId, string provider, string key); Task DeleteLogin(int userId, string provider, string key);
Task<bool> SendLoginLink(string email); Task<bool> SendLoginLink(string email, string returnurl);
} }
public class UserManager : IUserManager public class UserManager : IUserManager
@@ -960,7 +960,7 @@ namespace Oqtane.Managers
} }
} }
public async Task<bool> SendLoginLink(string email) public async Task<bool> SendLoginLink(string email, string returnurl)
{ {
try try
{ {
@@ -973,7 +973,7 @@ namespace Oqtane.Managers
var alias = _tenantManager.GetAlias(); var alias = _tenantManager.GetAlias();
var user = GetUser(identityuser.UserName, alias.SiteId); var user = GetUser(identityuser.UserName, alias.SiteId);
string url = alias.Protocol + alias.Name + "/pages/loginlink?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); string url = alias.Protocol + alias.Name + "/pages/loginlink?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token) + "&returnurl=" + WebUtility.UrlEncode(returnurl);
string siteName = _sites.GetSite(alias.SiteId).Name; string siteName = _sites.GetSite(alias.SiteId).Name;
string subject = _localizer["LoginLinkEmailSubject"]; string subject = _localizer["LoginLinkEmailSubject"];
subject = subject.Replace("[SiteName]", siteName); subject = subject.Replace("[SiteName]", siteName);

View File

@@ -27,38 +27,45 @@ namespace Oqtane.Pages
_logger = logger; _logger = logger;
} }
public async Task<IActionResult> OnGetAsync(string name, string token) public async Task<IActionResult> OnGetAsync(string name, string token, string returnurl)
{ {
var returnurl = "/login"; returnurl = (returnurl == null) ? "" : WebUtility.UrlDecode(returnurl);
if (bool.Parse(HttpContext.GetSiteSettings().GetValue("LoginOptions:LoginLink", "false")) && if (bool.Parse(HttpContext.GetSiteSettings().GetValue("LoginOptions:LoginLink", "false")) &&
!User.Identity.IsAuthenticated && !string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(token)) !string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(token))
{ {
var validuser = false; var validuser = false;
IdentityUser identityuser = await _identityUserManager.FindByNameAsync(name); if (!User.Identity.IsAuthenticated)
if (identityuser != null)
{ {
var result = await _identityUserManager.ConfirmEmailAsync(identityuser, token); IdentityUser identityuser = await _identityUserManager.FindByNameAsync(name);
if (result.Succeeded) if (identityuser != null)
{ {
await _identitySignInManager.SignInAsync(identityuser, false); var result = await _identityUserManager.ConfirmEmailAsync(identityuser, token);
_logger.Log(LogLevel.Information, this, LogFunction.Security, "Login Link Successful For User {Username}", name); if (result.Succeeded)
validuser = true; {
returnurl = "/"; await _identitySignInManager.SignInAsync(identityuser, false);
_logger.Log(LogLevel.Information, this, LogFunction.Security, "Login Link Successful For User {Username}", name);
validuser = true;
}
} }
} }
if (!validuser) if (!validuser)
{ {
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Login Link Failed For User {Username}", name); _logger.Log(LogLevel.Error, this, LogFunction.Security, "Login Link Failed For User {Username}", name);
returnurl += $"?status={ExternalLoginStatus.LoginLinkFailed}"; returnurl = HttpContext.GetAlias().Path + $"/login?status={ExternalLoginStatus.LoginLinkFailed}";
} }
} }
else else
{ {
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Login Link Attempt For User {Username}", name); _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Login Link Attempt For User {Username}", name);
returnurl = "/"; returnurl = HttpContext.GetAlias().Path;
}
if (!returnurl.StartsWith("/"))
{
returnurl = "/" + returnurl;
} }
return LocalRedirect(Url.Content("~" + returnurl)); return LocalRedirect(Url.Content("~" + returnurl));

View File

@@ -1,8 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
@@ -11,7 +9,6 @@ using Oqtane.Extensions;
using Oqtane.Infrastructure; using Oqtane.Infrastructure;
using Oqtane.Managers; using Oqtane.Managers;
using Oqtane.Shared; using Oqtane.Shared;
using Radzen.Blazor.Markdown;
namespace Oqtane.Pages namespace Oqtane.Pages
{ {

View File

@@ -10,6 +10,7 @@ using Oqtane.Infrastructure;
using Oqtane.Managers; using Oqtane.Managers;
using Oqtane.Security; using Oqtane.Security;
using Oqtane.Shared; using Oqtane.Shared;
using Oqtane.UI;
namespace Oqtane.Pages namespace Oqtane.Pages
{ {
@@ -103,7 +104,7 @@ namespace Oqtane.Pages
{ {
identityuser = null; identityuser = null;
var requestOptionsJson = await _identitySignInManager.MakePasskeyRequestOptionsAsync(identityuser); var requestOptionsJson = await _identitySignInManager.MakePasskeyRequestOptionsAsync(identityuser);
returnurl += $"?options={WebUtility.UrlEncode(requestOptionsJson)}"; returnurl = HttpContext.GetAlias().Path + $"/login?options={WebUtility.UrlEncode(requestOptionsJson)}&returnurl={WebUtility.UrlEncode(returnurl)}";
} }
else else
{ {
@@ -129,6 +130,7 @@ namespace Oqtane.Pages
else else
{ {
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Passkey Login Failed - Invalid Credential"); _logger.Log(LogLevel.Error, this, LogFunction.Security, "Passkey Login Failed - Invalid Credential");
returnurl = HttpContext.GetAlias().Path + $"/login?status={ExternalLoginStatus.PasskeyFailed}&returnurl={WebUtility.UrlEncode(returnurl)}";
} }
} }
else else

View File

@@ -11,5 +11,6 @@ namespace Oqtane.Shared {
public const string RemoteFailure = "RemoteFailure"; public const string RemoteFailure = "RemoteFailure";
public const string ReviewClaims = "ReviewClaims"; public const string ReviewClaims = "ReviewClaims";
public const string LoginLinkFailed = "LoginLinkFailed"; public const string LoginLinkFailed = "LoginLinkFailed";
public const string PasskeyFailed = "PasskeyFailed";
} }
} }