add passkey functionality

This commit is contained in:
sbwalker
2025-10-29 12:31:50 -04:00
parent e548c21c94
commit 7e69b5193f
18 changed files with 757 additions and 294 deletions

View File

@ -20,39 +20,50 @@ else
<div class="Oqtane-Modules-Admin-Login" @onkeypress="@(e => KeyPressed(e))"> <div class="Oqtane-Modules-Admin-Login" @onkeypress="@(e => KeyPressed(e))">
@if (_allowexternallogin) @if (_allowexternallogin)
{ {
<button type="button" class="btn btn-primary" @onclick="ExternalLogin">@Localizer["Use"] @PageState.Site.Settings["ExternalLogin:ProviderName"]</button> <button type="button" class="btn btn-primary col-12" @onclick="ExternalLogin">@Localizer["Use"] @PageState.Site.Settings["ExternalLogin:ProviderName"]</button>
<br /><br /> <hr class="app-rule mt-3 mb-2" />
} }
@if (_allowsitelogin) @if (_allowsitelogin)
{ {
<div class="form-group"> <div class="form-group text-center">
<Label Class="control-label" For="username" HelpText="Please enter your Username" ResourceKey="Username">Username:</Label> <Label Class="control-label" For="username" HelpText="Please enter your Username" ResourceKey="Username">Username:</Label>
<input id="username" type="text" @ref="username" class="form-control" placeholder="@Localizer["Username.Placeholder"]" @bind="@_username" @bind:event="oninput" required /> <input id="username" type="text" @ref="username" class="form-control" placeholder="@Localizer["Username.Placeholder"]" @bind="@_username" @bind:event="oninput" required />
</div> </div>
<div class="form-group mt-2"> <div class="form-group text-center mt-2">
<Label Class="control-label" For="password" HelpText="Please enter your Password" ResourceKey="Password">Password:</Label> <Label Class="control-label" For="password" HelpText="Please enter your Password" ResourceKey="Password">Password:</Label>
<div class="input-group"> <div class="input-group">
<input id="password" type="@_passwordtype" name="Password" class="form-control" placeholder="@Localizer["Password.Placeholder"]" @bind="@_password" @bind:event="oninput" required /> <input id="password" type="@_passwordtype" name="Password" class="form-control" placeholder="@Localizer["Password.Placeholder"]" @bind="@_password" @bind:event="oninput" required />
<button type="button" class="btn btn-secondary" @onclick="@TogglePassword" tabindex="-1">@_togglepassword</button> <button type="button" class="btn btn-secondary" @onclick="@TogglePassword" tabindex="-1">@_togglepassword</button>
</div> </div>
</div> </div>
<div class="form-group mt-2"> @if (!_alwaysremember)
@if (!_alwaysremember) {
{ <div class="form-group text-center mt-2">
<div class="form-check"> <div>
<input id="remember" type="checkbox" class="form-check-input" @bind="@_remember" /> <input id="remember" type="checkbox" class="form-check-input" @bind="@_remember" />
<Label Class="control-label" For="remember" HelpText="Specify if you would like to be signed back in automatically the next time you visit this site" ResourceKey="Remember">Remember Me?</Label> <Label Class="control-label" For="remember" HelpText="Specify if you would like to be signed back in automatically the next time you visit this site" ResourceKey="Remember">Remember Me?</Label>
</div> </div>
} </div>
}
<div class="btn-group mt-2 col-12" role="group">
<button type="button" class="btn btn-primary col-6" @onclick="Login">@SharedLocalizer["Login"]</button>
<button type="button" class="btn btn-secondary col-6" @onclick="Cancel">@SharedLocalizer["Cancel"]</button>
</div> </div>
<button type="button" class="btn btn-primary" @onclick="Login">@SharedLocalizer["Login"]</button> <button type="button" class="btn btn-secondary col-12 mt-4" @onclick="Forgot">@Localizer["ForgotPassword"]</button>
<button type="button" class="btn btn-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button>
<br /><br /> @if (_allowpasskeys)
<button type="button" class="btn btn-secondary" @onclick="Forgot">@Localizer["ForgotPassword"]</button>
@if (PageState.Site.AllowRegistration)
{ {
<br /><br /> <hr class="app-rule mt-3" />
<NavLink href="@NavigateUrl("register")">@Localizer["Register"]</NavLink> <button type="button" class="btn btn-primary col-12 mt-2" @onclick="Passkey">@Localizer["Passkey"]</button>
}
@if (PageState.Site.AllowRegistration)
{
<hr class="app-rule mt-3" />
<div class="text-center mt-2">
<NavLink href="@_registerurl">@Localizer["Register"]</NavLink>
</div>
} }
} }
</div> </div>
@ -77,6 +88,7 @@ else
@code { @code {
private bool _allowsitelogin = true; private bool _allowsitelogin = true;
private bool _allowexternallogin = false; private bool _allowexternallogin = false;
private bool _allowpasskeys = false;
private ElementReference login; private ElementReference login;
private bool validated = false; private bool validated = false;
private bool twofactor = false; private bool twofactor = false;
@ -88,6 +100,7 @@ else
private bool _remember = false; private bool _remember = false;
private bool _alwaysremember = false; private bool _alwaysremember = false;
private string _code = string.Empty; private string _code = string.Empty;
private string _registerurl = string.Empty;
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Anonymous; public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Anonymous;
public override bool? Prerender => true; public override bool? Prerender => true;
@ -103,8 +116,18 @@ else
{ {
_allowexternallogin = (SettingService.GetSetting(PageState.Site.Settings, "ExternalLogin:ProviderType", "") != "") ? true : false; _allowexternallogin = (SettingService.GetSetting(PageState.Site.Settings, "ExternalLogin:ProviderType", "") != "") ? true : false;
_allowsitelogin = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AllowSiteLogin", "true")); _allowsitelogin = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AllowSiteLogin", "true"));
_allowpasskeys = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AllowPasskeys", "false"));
_alwaysremember = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AlwaysRemember", "false")); _alwaysremember = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AlwaysRemember", "false"));
if (!string.IsNullOrEmpty(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:RegisterUrl", "")))
{
_registerurl = SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:RegisterUrl", "");
}
else
{
_registerurl = NavigateUrl("register");
}
_togglepassword = SharedLocalizer["ShowPassword"]; _togglepassword = SharedLocalizer["ShowPassword"];
if (PageState.QueryString.ContainsKey("name")) if (PageState.QueryString.ContainsKey("name"))
@ -163,23 +186,6 @@ else
} }
} }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && PageState.User == null && _allowsitelogin)
{
if (!string.IsNullOrEmpty(username.Id)) // ensure username is visible in UI
{
await username.FocusAsync();
}
}
// redirect logged in user to specified page
if (PageState.User != null && !UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin))
{
NavigationManager.NavigateTo(PageState.ReturnUrl);
}
}
private async Task Login() private async Task Login()
{ {
try try
@ -331,4 +337,58 @@ else
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(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"))
{
// user has initiated a passkey login
try
{
var interop = new Interop(JSRuntime);
var credential = await interop.RequestCredential(WebUtility.UrlDecode(PageState.QueryString["options"]));
if (!string.IsNullOrEmpty(credential))
{
// 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 };
string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/");
await interop.SubmitForm(url, fields);
}
else
{
await logger.LogError("Error Logging In With Passkey");
AddModuleMessage(Localizer["Error.Passkey"], MessageType.Error);
}
}
catch (Exception ex)
{
await logger.LogError(ex, "Error Logging In With Passkey");
AddModuleMessage(Localizer["Error.Passkey"], MessageType.Error);
}
return;
}
if (firstRender && PageState.User == null && _allowsitelogin)
{
if (!string.IsNullOrEmpty(username.Id)) // ensure username is visible in UI
{
await username.FocusAsync();
}
}
// redirect logged in user to specified page
if (PageState.User != null && !UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin))
{
NavigationManager.NavigateTo(PageState.ReturnUrl);
}
}
} }

View File

@ -26,7 +26,7 @@
<br /> <br />
} }
<TabStrip> <TabStrip>
<TabPanel Name="Identity" ResourceKey="Identity"> <TabPanel Name="Identity" Heading="Identity" ResourceKey="Identity">
<div class="container"> <div class="container">
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="username" HelpText="Your username. Note that this field can not be modified." ResourceKey="Username"></Label> <Label Class="col-sm-3" For="username" HelpText="Your username. Note that this field can not be modified." ResourceKey="Username"></Label>
@ -69,7 +69,7 @@
<button type="button" class="btn btn-success" @onclick="Save">@SharedLocalizer["Save"]</button> <button type="button" class="btn btn-success" @onclick="Save">@SharedLocalizer["Save"]</button>
<button type="button" class="btn btn-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button> <button type="button" class="btn btn-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button>
</TabPanel> </TabPanel>
<TabPanel Name="Security" ResourceKey="Security"> <TabPanel Name="Security" Heading="Security" ResourceKey="Security">
<ModuleMessage Message="@_passwordrequirements" Type="MessageType.Info" /> <ModuleMessage Message="@_passwordrequirements" Type="MessageType.Info" />
<div class="container"> <div class="container">
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
@ -110,40 +110,70 @@
</div> </div>
</div> </div>
</Section> </Section>
<br />
} }
<Section Name="External" Heading="External Login" ResourceKey="External"> @if (_allowpasskeys)
</Section> {
<Section Name="Passkeys" Heading="Passkeys" ResourceKey="Passkeys"> <Section Name="Passkeys" Heading="Passkeys" ResourceKey="Passkeys">
<button type="button" class="btn btn-primary" @onclick="AddPasskey">@SharedLocalizer["Add"]</button> @if (PageState.Route.Scheme == "https")
<Pager Items="@_passkeys"> {
<Header> <button type="button" class="btn btn-primary" @onclick="AddPasskey">@SharedLocalizer["Add"]</button>
<th style="width: 1px;">&nbsp;</th> }
<th style="width: 1px;">&nbsp;</th> else
<th>@Localizer["Passkey"]</th> {
</Header> <ModuleMessage Type="MessageType.Warning" Message="@Localizer["Message.Passkey.Insecure"]" />
<Row> }
@if (context.CredentialId != _passkeyId) @if (_passkeys != null && _passkeys.Count > 0)
{ {
<td><button type="button" class="btn btn-primary" @onclick="@(() => EditPasskey(context))">@SharedLocalizer["Edit"]</button></td> <Pager Items="@_passkeys">
<td><ActionDialog Action="Delete" OnClick="@(async () => await DeletePasskey(context))" ResourceKey="DeleteAlias" Class="btn btn-danger" Header="Delete Alias" Message="@string.Format(Localizer["Confirm.Passkey.Delete", context.CredentialId])" /></td> <Header>
<td>@context.Name</td> <th style="width: 1px;">&nbsp;</th>
} <th style="width: 1px;">&nbsp;</th>
else <th>@Localizer["Passkey"]</th>
{ </Header>
<td><button type="button" class="btn btn-success" @onclick="@(async () => await SavePasskey())">@SharedLocalizer["Save"]</button></td> <Row>
<td><button type="button" class="btn btn-secondary" @onclick="@(async () => await CancelPasskey())">@SharedLocalizer["Cancel"]</button></td> @if (context.CredentialId != _passkeyId)
<td><input id="aliasname" class="form-control" @bind="@_passkeyName" /></td> {
} <td><button type="button" class="btn btn-primary" @onclick="@(() => EditPasskey(context))">@SharedLocalizer["Edit"]</button></td>
</Row> <td><ActionDialog Action="Delete" OnClick="@(async () => await DeletePasskey(context))" ResourceKey="DeletePasskey" Class="btn btn-danger" Header="Delete Passkey" Message="@string.Format(Localizer["Confirm.Passkey.Delete", context.Name])" /></td>
</Pager> <td>@context.Name</td>
<br /><br /> }
</Section> else
<Section Name="Logout" Heading="Logout" ResourceKey="Logout"> {
<button type="button" class="btn btn-danger" @onclick="Logout">@Localizer["Logout Everywhere"]</button> <td><button type="button" class="btn btn-success" @onclick="@(async () => await SavePasskey())">@SharedLocalizer["Save"]</button></td>
</Section> <td><button type="button" class="btn btn-secondary" @onclick="@(async () => await CancelPasskey())">@SharedLocalizer["Cancel"]</button></td>
<td><input id="passkeyname" class="form-control" @bind="@_passkeyName" /></td>
}
</Row>
</Pager>
}
</Section>
<br />
}
@if (_allowexternallogin)
{
<Section Name="Logins" Heading="Logins" ResourceKey="Logins">
@if (_logins != null && _logins.Count > 0)
{
<Pager Items="@_logins">
<Header>
<th style="width: 1px;">&nbsp;</th>
<th>@Localizer["Login"]</th>
</Header>
<Row>
<td><ActionDialog Action="Delete" OnClick="@(async () => await DeleteLogin(context))" ResourceKey="DeleteLogin" Class="btn btn-danger" Header="Delete Login" Message="@string.Format(Localizer["Confirm.Login.Delete", context.Name])" /></td>
<td>@context.Name</td>
</Row>
</Pager>
}
</Section>
<br />
}
<br />
<button type="button" class="btn btn-danger" @onclick="Logout">@Localizer["Logout Everywhere"]</button>
<br /> <br />
</TabPanel> </TabPanel>
<TabPanel Name="Profile" ResourceKey="Profile"> <TabPanel Name="Profile" Heading="Profile" ResourceKey="Profile">
<div class="container"> <div class="container">
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
@foreach (Profile profile in _profiles) @foreach (Profile profile in _profiles)
@ -272,11 +302,11 @@
<button type="button" class="btn btn-success" @onclick="Save">@SharedLocalizer["Save"]</button> <button type="button" class="btn btn-success" @onclick="Save">@SharedLocalizer["Save"]</button>
<button type="button" class="btn btn-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button> <button type="button" class="btn btn-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button>
</TabPanel> </TabPanel>
<TabPanel Name="Notifications" ResourceKey="Notifications"> <TabPanel Name="Notifications" Heading="Notifications" ResourceKey="Notifications">
<ActionLink Action="Add" Text="Send Notification" Security="SecurityAccessLevel.View" EditMode="false" ResourceKey="SendNotification" ReturnUrl="@NavigateUrl(PageState.Page.Path, "tab=Notifications")" /> <ActionLink Action="Add" Text="Send Notification" Security="SecurityAccessLevel.View" EditMode="false" ResourceKey="SendNotification" ReturnUrl="@NavigateUrl(PageState.Page.Path, "tab=Notifications")" />
<br /> <br />
<br /> <br />
<select class="form-select" @onchange="(e => FilterChanged(e))"> <select class="form-select" @onchange="(e => FilterNotifications(e))">
<option value="to">@Localizer["Inbox"]</option> <option value="to">@Localizer["Inbox"]</option>
<option value="from">@Localizer["Items.Sent"]</option> <option value="from">@Localizer["Items.Sent"]</option>
</select> </select>
@ -295,7 +325,7 @@
</Header> </Header>
<Row> <Row>
<td><ActionLink Action="View" Parameters="@($"id=" + context.NotificationId.ToString())" Security="SecurityAccessLevel.View" EditMode="false" ResourceKey="ViewNotification" ReturnUrl="@NavigateUrl(PageState.Page.Path, "tab=Notifications")" /></td> <td><ActionLink Action="View" Parameters="@($"id=" + context.NotificationId.ToString())" Security="SecurityAccessLevel.View" EditMode="false" ResourceKey="ViewNotification" ReturnUrl="@NavigateUrl(PageState.Page.Path, "tab=Notifications")" /></td>
<td><ActionDialog Header="Delete Notification" Message="Are You Sure You Wish To Delete This Notification?" Action="Delete" Security="SecurityAccessLevel.View" Class="btn btn-danger" OnClick="@(async () => await Delete(context))" EditMode="false" ResourceKey="DeleteNotification" /></td> <td><ActionDialog Header="Delete Notification" Message="Are You Sure You Wish To Delete This Notification?" Action="Delete" Security="SecurityAccessLevel.View" Class="btn btn-danger" OnClick="@(async () => await DeleteNotification(context))" EditMode="false" ResourceKey="DeleteNotification" /></td>
@if (context.IsRead) @if (context.IsRead)
{ {
@ -358,7 +388,7 @@
</Header> </Header>
<Row> <Row>
<td><ActionLink Action="View" Parameters="@($"id=" + context.NotificationId.ToString())" Security="SecurityAccessLevel.View" EditMode="false" ResourceKey="ViewNotification" ReturnUrl="@NavigateUrl(PageState.Page.Path, "tab=Notifications")" /></td> <td><ActionLink Action="View" Parameters="@($"id=" + context.NotificationId.ToString())" Security="SecurityAccessLevel.View" EditMode="false" ResourceKey="ViewNotification" ReturnUrl="@NavigateUrl(PageState.Page.Path, "tab=Notifications")" /></td>
<td><ActionDialog Header="Delete Notification" Message="Are You Sure You Wish To Delete This Notification?" Action="Delete" Security="SecurityAccessLevel.View" Class="btn btn-danger" OnClick="@(async () => await Delete(context))" EditMode="false" ResourceKey="DeleteNotification" /></td> <td><ActionDialog Header="Delete Notification" Message="Are You Sure You Wish To Delete This Notification?" Action="Delete" Security="SecurityAccessLevel.View" Class="btn btn-danger" OnClick="@(async () => await DeleteNotification(context))" EditMode="false" ResourceKey="DeleteNotification" /></td>
@if (context.IsRead) @if (context.IsRead)
{ {
@ -415,15 +445,14 @@
} }
@code { @code {
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.View;
private bool _initialized = false; private bool _initialized = false;
private string _passwordrequirements;
private string _username = string.Empty;
private string _password = string.Empty;
private string _passwordtype = "password";
private string _togglepassword = string.Empty;
private string _confirm = string.Empty;
private bool _allowtwofactor = false; private bool _allowtwofactor = false;
private string _twofactor = "False"; private bool _allowpasskeys = false;
private bool _allowexternallogin = false;
private string _username = string.Empty;
private string _email = string.Empty; private string _email = string.Empty;
private string _displayname = string.Empty; private string _displayname = string.Empty;
private FileManager _filemanager; private FileManager _filemanager;
@ -434,9 +463,16 @@
private File _photo = null; private File _photo = null;
private string _imagefiles = string.Empty; private string _imagefiles = string.Empty;
private List<Passkey> _passkeys; private string _passwordrequirements;
private string _password = string.Empty;
private string _passwordtype = "password";
private string _togglepassword = string.Empty;
private string _confirm = string.Empty;
private string _twofactor = "False";
private List<UserPasskey> _passkeys;
private byte[] _passkeyId; private byte[] _passkeyId;
private string _passkeyName = string.Empty; private string _passkeyName = string.Empty;
private List<UserLogin> _logins;
private List<Profile> _profiles; private List<Profile> _profiles;
private Dictionary<string, string> _userSettings; private Dictionary<string, string> _userSettings;
@ -446,45 +482,29 @@
private List<Notification> _notifications; private List<Notification> _notifications;
private string _notificationSummary = string.Empty; private string _notificationSummary = string.Empty;
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.View;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try try
{ {
_passwordrequirements = await UserService.GetPasswordRequirementsAsync(PageState.Site.SiteId);
_togglepassword = SharedLocalizer["ShowPassword"];
_allowtwofactor = (SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:TwoFactor", "false") == "true"); _allowtwofactor = (SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:TwoFactor", "false") == "true");
_profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId); _allowpasskeys = (SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:Passkeys", "false") == "true");
foreach (var profile in _profiles) _allowexternallogin = (SettingService.GetSetting(PageState.Site.Settings, "ExternalLogin:ProviderType", "") != "") ? true : false;
{
if (profile.Options.ToLower().StartsWith("entityname:"))
{
var options = await SettingService.GetSettingsAsync(profile.Options.Substring(11), -1);
options.Add("", $"<{SharedLocalizer["Not Specified"]}>");
profile.Options = string.Join(",", options.OrderBy(item => item.Value).Select(kvp => $"{kvp.Key}:{kvp.Value}"));
}
}
_timezones = TimeZoneService.GetTimeZones();
if (PageState.User != null) if (PageState.User != null)
{ {
// identity section
_username = PageState.User.Username; _username = PageState.User.Username;
_twofactor = PageState.User.TwoFactorRequired.ToString();
_email = PageState.User.Email; _email = PageState.User.Email;
_displayname = PageState.User.DisplayName; _displayname = PageState.User.DisplayName;
_timezoneid = PageState.User.TimeZoneId; _timezones = TimeZoneService.GetTimeZones();
_timezoneid = PageState.User.TimeZoneId;
// get user folder
var folder = await FolderService.GetFolderAsync(ModuleState.SiteId, PageState.User.FolderPath); var folder = await FolderService.GetFolderAsync(ModuleState.SiteId, PageState.User.FolderPath);
if (folder != null) if (folder != null)
{ {
_folderid = folder.FolderId; _folderid = folder.FolderId;
} }
_imagefiles = SettingService.GetSetting(PageState.Site.Settings, "ImageFiles", Constants.ImageFiles); _imagefiles = SettingService.GetSetting(PageState.Site.Settings, "ImageFiles", Constants.ImageFiles);
_imagefiles = (string.IsNullOrEmpty(_imagefiles)) ? Constants.ImageFiles : _imagefiles; _imagefiles = (string.IsNullOrEmpty(_imagefiles)) ? Constants.ImageFiles : _imagefiles;
if (PageState.User.PhotoFileId != null) if (PageState.User.PhotoFileId != null)
{ {
_photofileid = PageState.User.PhotoFileId.Value; _photofileid = PageState.User.PhotoFileId.Value;
@ -496,8 +516,27 @@
_photo = null; _photo = null;
} }
// security section
_passwordrequirements = await UserService.GetPasswordRequirementsAsync(PageState.Site.SiteId);
_togglepassword = SharedLocalizer["ShowPassword"];
_twofactor = PageState.User.TwoFactorRequired.ToString();
await GetPasskeys();
await GetLogins();
// profile section
_profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId);
foreach (var profile in _profiles)
{
if (profile.Options.ToLower().StartsWith("entityname:"))
{
var options = await SettingService.GetSettingsAsync(profile.Options.Substring(11), -1);
options.Add("", $"<{SharedLocalizer["Not Specified"]}>");
profile.Options = string.Join(",", options.OrderBy(item => item.Value).Select(kvp => $"{kvp.Key}:{kvp.Value}"));
}
}
_userSettings = PageState.User.Settings; _userSettings = PageState.User.Settings;
// notification section
await LoadNotificationsAsync(); await LoadNotificationsAsync();
_initialized = true; _initialized = true;
@ -514,22 +553,7 @@
} }
} }
private async Task LoadNotificationsAsync() // identity methods
{
_notifications = await NotificationService.GetNotificationsAsync(PageState.Site.SiteId, _filter, PageState.User.UserId);
_notifications = _notifications.Where(item => item.DeletedBy != PageState.User.Username).ToList();
}
private string GetProfileValue(string SettingName, string DefaultValue)
{
string value = SettingService.GetSetting(_userSettings, SettingName, DefaultValue);
if (value.Contains("]"))
{
value = value.Substring(value.IndexOf("]") + 1);
}
return value;
}
private async Task Save() private async Task Save()
{ {
try try
@ -604,6 +628,124 @@
} }
} }
private void Cancel()
{
NavigationManager.NavigateTo(PageState.ReturnUrl);
}
// security methods
private void TogglePassword()
{
if (_passwordtype == "password")
{
_passwordtype = "text";
_togglepassword = SharedLocalizer["HidePassword"];
}
else
{
_passwordtype = "password";
_togglepassword = SharedLocalizer["ShowPassword"];
}
}
private async Task GetPasskeys()
{
if (_allowpasskeys)
{
_passkeys = await UserService.GetPasskeysAsync();
}
}
private async Task AddPasskey()
{
// 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 = "create", returnurl = NavigateUrl() };
string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/");
await interop.SubmitForm(url, fields);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// user has initiated a passkey addition
if (PageState.QueryString.ContainsKey("options"))
{
try
{
var interop = new Interop(JSRuntime);
var credential = await interop.CreateCredential(WebUtility.UrlDecode(PageState.QueryString["options"]));
if (!string.IsNullOrEmpty(credential))
{
// post back to the Passkey page so that the cookies are set correctly
var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, operation = "validate", credential = credential, returnurl = NavigateUrl(PageState.Page.Path, "tab=Security") };
string url = Utilities.TenantUrl(PageState.Alias, "/pages/passkey/");
await interop.SubmitForm(url, fields);
}
else
{
await logger.LogError("Error Adding Passkey");
AddModuleMessage(Localizer["Error.Passkey"], MessageType.Error);
}
}
catch (Exception ex)
{
await logger.LogError(ex, "Error Adding Passkey");
AddModuleMessage(Localizer["Error.Passkey"], MessageType.Error);
}
}
}
}
private void EditPasskey(UserPasskey passkey)
{
_passkeyId = passkey.CredentialId;
_passkeyName = passkey.Name;
StateHasChanged();
}
private async Task DeletePasskey(UserPasskey passkey)
{
await UserService.DeletePasskeyAsync(passkey.CredentialId);
await GetPasskeys();
StateHasChanged();
}
private async Task SavePasskey()
{
if (!string.IsNullOrEmpty(_passkeyName))
{
await UserService.UpdatePasskeyAsync(new UserPasskey { CredentialId = _passkeyId, Name = _passkeyName });
await GetPasskeys();
_passkeyName = "";
StateHasChanged();
}
}
private async Task CancelPasskey()
{
await GetPasskeys();
_passkeyName = "";
StateHasChanged();
}
private async Task GetLogins()
{
if (_allowexternallogin)
{
_logins = await UserService.GetLoginsAsync();
}
}
private async Task DeleteLogin(UserLogin login)
{
await UserService.DeleteLoginAsync(login.Provider, login.Key);
await GetLogins();
StateHasChanged();
}
private async Task Logout() private async Task Logout()
{ {
await logger.LogInformation("User Logout Everywhere For Username {Username}", PageState.User?.Username); await logger.LogInformation("User Logout Everywhere For Username {Username}", PageState.User?.Username);
@ -630,51 +772,24 @@
} }
} }
private async Task GetPasskeys() // profile methods
{
_passkeys = await UserService.GetPasskeysAsync();
}
private async Task AddPasskey()
{
_passkeyName = $"{PageState.User.DisplayName}{_passkeys.Count + 1}"; // set default name
await UserService.AddPasskeyAsync(new Passkey { Name = _passkeyName, CredentialJson = "" });
await GetPasskeys();
StateHasChanged();
}
private void EditPasskey(Passkey passkey) private string GetProfileValue(string SettingName, string DefaultValue)
{ {
_passkeyId = passkey.CredentialId; string value = SettingService.GetSetting(_userSettings, SettingName, DefaultValue);
_passkeyName = passkey.Name; if (value.Contains("]"))
StateHasChanged();
}
private async Task DeletePasskey(Passkey passkey)
{
await UserService.DeletePasskeyAsync(passkey.CredentialId);
await GetPasskeys();
StateHasChanged();
}
private async Task SavePasskey()
{
if (!string.IsNullOrEmpty(_passkeyName))
{ {
await UserService.UpdatePasskeyAsync(new Passkey { CredentialId = _passkeyId, Name = _passkeyName }); value = value.Substring(value.IndexOf("]") + 1);
await GetPasskeys();
_passkeyName = "";
StateHasChanged();
} }
return value;
} }
private async Task CancelPasskey() private void ProfileChanged(ChangeEventArgs e, string SettingName)
{ {
await GetPasskeys(); var value = (string)e.Value;
_passkeyName = ""; _userSettings = SettingService.SetSetting(_userSettings, SettingName, value);
StateHasChanged();
} }
private bool ValidateProfiles() private bool ValidateProfiles()
{ {
foreach (Profile profile in _profiles) foreach (Profile profile in _profiles)
@ -706,18 +821,22 @@
return true; return true;
} }
private void Cancel() // notification methods
private async Task LoadNotificationsAsync()
{ {
NavigationManager.NavigateTo(PageState.ReturnUrl); _notifications = await NotificationService.GetNotificationsAsync(PageState.Site.SiteId, _filter, PageState.User.UserId);
_notifications = _notifications.Where(item => item.DeletedBy != PageState.User.Username).ToList();
} }
private void ProfileChanged(ChangeEventArgs e, string SettingName) private async void FilterNotifications(ChangeEventArgs e)
{ {
var value = (string)e.Value; _filter = (string)e.Value;
_userSettings = SettingService.SetSetting(_userSettings, SettingName, value); await LoadNotificationsAsync();
StateHasChanged();
} }
private async Task Delete(Notification Notification) private async Task DeleteNotification(Notification Notification)
{ {
try try
{ {
@ -742,13 +861,6 @@
} }
} }
private async void FilterChanged(ChangeEventArgs e)
{
_filter = (string)e.Value;
await LoadNotificationsAsync();
StateHasChanged();
}
private async Task DeleteAllNotifications() private async Task DeleteAllNotifications()
{ {
try try
@ -780,18 +892,4 @@
HideProgressIndicator(); HideProgressIndicator();
} }
} }
private void TogglePassword()
{
if (_passwordtype == "password")
{
_passwordtype = "text";
_togglepassword = SharedLocalizer["HidePassword"];
}
else
{
_passwordtype = "password";
_togglepassword = SharedLocalizer["ShowPassword"];
}
}
} }

View File

@ -72,32 +72,41 @@ else
</select> </select>
</div> </div>
</div> </div>
@if (_allowregistration == "true")
{
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="registerurl" HelpText="Optionally provide a custom registration url" ResourceKey="RegisterUrl">Register Url:</Label>
<div class="col-sm-9">
<input id="registerurl" class="form-control" @bind="@_registerurl" />
</div>
</div>
}
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="profileurl" HelpText="Optionally provide a custom profile url" ResourceKey="ProfileUrl">Profile Url:</Label>
<div class="col-sm-9">
<input id="profileurl" class="form-control" @bind="@_profileurl" />
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="requireconfirmedemail" HelpText="Do you want to require registered users to verify their email address before they are allowed to log in?" ResourceKey="RequireConfirmedEmail">Require Verified Email?</Label>
<div class="col-sm-9">
<select id="requireconfirmedemail" class="form-select" @bind="@_requireconfirmedemail">
<option value="true">@SharedLocalizer["Yes"]</option>
<option value="false">@SharedLocalizer["No"]</option>
</select>
</div>
</div>
@if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) @if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host))
{ {
@if (_allowregistration == "true")
{
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="registerurl" HelpText="Optionally provide a custom registration url" ResourceKey="RegisterUrl">Register Url:</Label>
<div class="col-sm-9">
<input id="registerurl" class="form-control" @bind="@_registerurl" />
</div>
</div>
}
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="profileurl" HelpText="Optionally provide a custom profile url" ResourceKey="ProfileUrl">Profile Url:</Label>
<div class="col-sm-9">
<input id="profileurl" class="form-control" @bind="@_profileurl" />
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="requireconfirmedemail" HelpText="Do you want to require registered users to verify their email address before they are allowed to log in?" ResourceKey="RequireConfirmedEmail">Require Verified Email?</Label>
<div class="col-sm-9">
<select id="requireconfirmedemail" class="form-select" @bind="@_requireconfirmedemail">
<option value="true">@SharedLocalizer["Yes"]</option>
<option value="false">@SharedLocalizer["No"]</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="passkeys" HelpText="Do you want to allow users to login using passkeys (ie. passwordless authentication using WebAuthn/FIDO2)" ResourceKey="Passkeys">Allow Passkeys?</Label>
<div class="col-sm-9">
<select id="passkeys" class="form-select" @bind="@_passkeys">
<option value="true">@SharedLocalizer["Yes"]</option>
<option value="false">@SharedLocalizer["No"]</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="twofactor" HelpText="Do you want users to use two factor authentication? Note that you should use the Disabled option until you have successfully verified that the Notification Job in Scheduled Jobs is enabled and your SMTP options in Site Settings are configured or else you will lock yourself out." ResourceKey="TwoFactor">Two Factor Authentication?</Label> <Label Class="col-sm-3" For="twofactor" HelpText="Do you want users to use two factor authentication? Note that you should use the Disabled option until you have successfully verified that the Notification Job in Scheduled Jobs is enabled and your SMTP options in Site Settings are configured or else you will lock yourself out." ResourceKey="TwoFactor">Two Factor Authentication?</Label>
<div class="col-sm-9"> <div class="col-sm-9">
@ -538,6 +547,7 @@ else
private string _registerurl; private string _registerurl;
private string _profileurl; private string _profileurl;
private string _requireconfirmedemail; private string _requireconfirmedemail;
private string _passkeys;
private string _twofactor; private string _twofactor;
private string _cookiename; private string _cookiename;
private string _cookiedomain; private string _cookiedomain;
@ -609,12 +619,13 @@ else
var settings = await SettingService.GetSiteSettingsAsync(PageState.Site.SiteId); var settings = await SettingService.GetSiteSettingsAsync(PageState.Site.SiteId);
_allowregistration = PageState.Site.AllowRegistration.ToString().ToLower(); _allowregistration = PageState.Site.AllowRegistration.ToString().ToLower();
_registerurl = SettingService.GetSetting(settings, "LoginOptions:RegisterUrl", "");
_profileurl = SettingService.GetSetting(settings, "LoginOptions:ProfileUrl", "");
_requireconfirmedemail = SettingService.GetSetting(settings, "LoginOptions:RequireConfirmedEmail", "true");
if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host))
{ {
_registerurl = SettingService.GetSetting(settings, "LoginOptions:RegisterUrl", "");
_profileurl = SettingService.GetSetting(settings, "LoginOptions:ProfileUrl", "");
_requireconfirmedemail = SettingService.GetSetting(settings, "LoginOptions:RequireConfirmedEmail", "true");
_passkeys = SettingService.GetSetting(settings, "LoginOptions:Passkeys", "false");
_twofactor = SettingService.GetSetting(settings, "LoginOptions:TwoFactor", "false"); _twofactor = SettingService.GetSetting(settings, "LoginOptions:TwoFactor", "false");
_cookiename = SettingService.GetSetting(settings, "LoginOptions:CookieName", ".AspNetCore.Identity.Application"); _cookiename = SettingService.GetSetting(settings, "LoginOptions:CookieName", ".AspNetCore.Identity.Application");
_cookiedomain = SettingService.GetSetting(settings, "LoginOptions:CookieDomain", ""); _cookiedomain = SettingService.GetSetting(settings, "LoginOptions:CookieDomain", "");
@ -753,6 +764,7 @@ else
settings = SettingService.SetSetting(settings, "LoginOptions:RegisterUrl", _registerurl, false); settings = SettingService.SetSetting(settings, "LoginOptions:RegisterUrl", _registerurl, false);
settings = SettingService.SetSetting(settings, "LoginOptions:ProfileUrl", _profileurl, false); settings = SettingService.SetSetting(settings, "LoginOptions:ProfileUrl", _profileurl, false);
settings = SettingService.SetSetting(settings, "LoginOptions:RequireConfirmedEmail", _requireconfirmedemail, false); settings = SettingService.SetSetting(settings, "LoginOptions:RequireConfirmedEmail", _requireconfirmedemail, false);
settings = SettingService.SetSetting(settings, "LoginOptions:Passkeys", _passkeys, false);
settings = SettingService.SetSetting(settings, "LoginOptions:TwoFactor", _twofactor, false); settings = SettingService.SetSetting(settings, "LoginOptions:TwoFactor", _twofactor, false);
settings = SettingService.SetSetting(settings, "LoginOptions:CookieName", _cookiename, true); settings = SettingService.SetSetting(settings, "LoginOptions:CookieName", _cookiename, true);
settings = SettingService.SetSetting(settings, "LoginOptions:CookieDomain", _cookiedomain, true); settings = SettingService.SetSetting(settings, "LoginOptions:CookieDomain", _cookiedomain, true);

View File

@ -118,7 +118,7 @@
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader> </resheader>
<data name="ForgotPassword" xml:space="preserve"> <data name="ForgotPassword" xml:space="preserve">
<value>Forgot Password</value> <value>Forgot Password?</value>
</data> </data>
<data name="Success.Account.Verified" xml:space="preserve"> <data name="Success.Account.Verified" xml:space="preserve">
<value>User Account Email Address Verified Successfully. You Can Now Login With Your Username And Password.</value> <value>User Account Email Address Verified Successfully. You Can Now Login With Your Username And Password.</value>
@ -231,4 +231,7 @@
<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>
<data name="Passkey" xml:space="preserve">
<value>Use Passkey</value>
</data>
</root> </root>

View File

@ -168,9 +168,6 @@
<data name="DeleteNotification.Message" xml:space="preserve"> <data name="DeleteNotification.Message" xml:space="preserve">
<value>Are You Sure You Wish To Delete This Notification?</value> <value>Are You Sure You Wish To Delete This Notification?</value>
</data> </data>
<data name="Identity.Name" xml:space="preserve">
<value>Identity</value>
</data>
<data name="Confirm.HelpText" xml:space="preserve"> <data name="Confirm.HelpText" xml:space="preserve">
<value>If you are changing your password you must enter it again to confirm it matches the value entered above</value> <value>If you are changing your password you must enter it again to confirm it matches the value entered above</value>
</data> </data>
@ -211,7 +208,7 @@
<value>Indicates if you are using two factor authentication. Two factor authentication requires you to enter a verification code sent via email after you sign in.</value> <value>Indicates if you are using two factor authentication. Two factor authentication requires you to enter a verification code sent via email after you sign in.</value>
</data> </data>
<data name="TwoFactor.Text" xml:space="preserve"> <data name="TwoFactor.Text" xml:space="preserve">
<value>Two Factor?</value> <value>Use Two Factor?</value>
</data> </data>
<data name="DeleteAllNotifications.Header" xml:space="preserve"> <data name="DeleteAllNotifications.Header" xml:space="preserve">
<value>Clear Notifications</value> <value>Clear Notifications</value>
@ -249,4 +246,40 @@
<data name="TimeZone.HelpText" xml:space="preserve"> <data name="TimeZone.HelpText" xml:space="preserve">
<value>Your time zone</value> <value>Your time zone</value>
</data> </data>
<data name="Identity.Heading" xml:space="preserve">
<value>Identity</value>
</data>
<data name="Security.Heading" xml:space="preserve">
<value>Security</value>
</data>
<data name="MFA.Heading" xml:space="preserve">
<value>Multi-Factor Authenticationxxx</value>
</data>
<data name="Passkeys.Heading" xml:space="preserve">
<value>Passkeys</value>
</data>
<data name="Logins.Heading" xml:space="preserve">
<value>Logins</value>
</data>
<data name="Passkey" xml:space="preserve">
<value>Passkey</value>
</data>
<data name="DeletePasskey.Header" xml:space="preserve">
<value>Delete Passkey</value>
</data>
<data name="Confirm.Passkey.Delete" xml:space="preserve">
<value>Are You Sure You Wish To Delete {0}?</value>
</data>
<data name="Login" xml:space="preserve">
<value>Login</value>
</data>
<data name="DeleteLogin.Header" xml:space="preserve">
<value>Delete Login</value>
</data>
<data name="Confirm.Login.Delete" xml:space="preserve">
<value>Are You Sure You Wish To Delete {0}?</value>
</data>
<data name="Message.Passkey.Insecure" xml:space="preserve">
<value>Passkeys Can Only Be Created Using a Secure Browser Connection</value>
</data>
</root> </root>

View File

@ -561,4 +561,10 @@
<data name="SingleLogout.HelpText" xml:space="preserve"> <data name="SingleLogout.HelpText" xml:space="preserve">
<value>Specify if users should be logged out of both the application and provider (the default is false indicating they will only be logged out of the application)</value> <value>Specify if users should be logged out of both the application and provider (the default is false indicating they will only be logged out of the application)</value>
</data> </data>
<data name="Passkeys.Text" xml:space="preserve">
<value>Allow Passkeys?</value>
</data>
<data name="Passkeys.HelpText" xml:space="preserve">
<value>Do you want to allow users to login using passkeys (ie. passwordless authentication using WebAuthn/FIDO2)?</value>
</data>
</root> </root>

View File

@ -3,12 +3,14 @@ using System.Collections.Generic;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Oqtane.Enums; using Oqtane.Enums;
using Oqtane.Models; using Oqtane.Models;
using Oqtane.Shared; using Oqtane.Shared;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace Oqtane.Services namespace Oqtane.Services
{ {
@ -206,6 +208,17 @@ namespace Oqtane.Services
await CheckResponse(response, uri); await CheckResponse(response, uri);
} }
protected async Task<string> PostStringAsync(string uri)
{
var response = await GetHttpClient().PostAsync(uri, null);
if (await CheckResponse(response, uri) && ValidateJsonContent(response.Content))
{
var result = await response.Content.ReadAsStringAsync();
return result;
}
return default;
}
protected async Task<T> PostJsonAsync<T>(string uri, T value) protected async Task<T> PostJsonAsync<T>(string uri, T value)
{ {
return await PostJsonAsync<T, T>(uri, value); return await PostJsonAsync<T, T>(uri, value);

View File

@ -1,11 +1,12 @@
using Oqtane.Shared; using System.Buffers.Text;
using Oqtane.Models; using System.Collections.Generic;
using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using Oqtane.Documentation;
using System.Net;
using System.Collections.Generic;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Oqtane.Documentation;
using Oqtane.Models;
using Oqtane.Shared;
namespace Oqtane.Services namespace Oqtane.Services
{ {
@ -177,21 +178,14 @@ namespace Oqtane.Services
/// Get passkeys for a user /// Get passkeys for a user
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
Task<List<Passkey>> GetPasskeysAsync(); Task<List<UserPasskey>> GetPasskeysAsync();
/// <summary>
/// Add a user passkey
/// </summary>
/// <param name="passkey"></param>
/// <returns></returns>
Task<Passkey> AddPasskeyAsync(Passkey passkey);
/// <summary> /// <summary>
/// Update a user passkey /// Update a user passkey
/// </summary> /// </summary>
/// <param name="passkey"></param> /// <param name="passkey"></param>
/// <returns></returns> /// <returns></returns>
Task<Passkey> UpdatePasskeyAsync(Passkey passkey); Task<UserPasskey> UpdatePasskeyAsync(UserPasskey passkey);
/// <summary> /// <summary>
/// Delete a user passkey /// Delete a user passkey
@ -199,6 +193,20 @@ namespace Oqtane.Services
/// <param name="credentialId"></param> /// <param name="credentialId"></param>
/// <returns></returns> /// <returns></returns>
Task DeletePasskeyAsync(byte[] credentialId); Task DeletePasskeyAsync(byte[] credentialId);
/// <summary>
/// Get logins for a user
/// </summary>
/// <returns></returns>
Task<List<UserLogin>> GetLoginsAsync();
/// <summary>
/// Delete a user login
/// </summary>
/// <param name="provider"></param>
/// <param name="key"></param>
/// <returns></returns>
Task DeleteLoginAsync(string provider, string key);
} }
[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")]
@ -330,24 +338,29 @@ namespace Oqtane.Services
return await PostJsonAsync<Dictionary<string, string>>($"{Apiurl}/import?siteid={siteId}&fileid={fileId}&notify={notify}", null); return await PostJsonAsync<Dictionary<string, string>>($"{Apiurl}/import?siteid={siteId}&fileid={fileId}&notify={notify}", null);
} }
public async Task<List<Passkey>> GetPasskeysAsync() public async Task<List<UserPasskey>> GetPasskeysAsync()
{ {
return await GetJsonAsync<List<Passkey>>($"{Apiurl}/passkey"); return await GetJsonAsync<List<UserPasskey>>($"{Apiurl}/passkey");
} }
public async Task<Passkey> AddPasskeyAsync(Passkey passkey) public async Task<UserPasskey> UpdatePasskeyAsync(UserPasskey passkey)
{ {
return await PostJsonAsync<Passkey>($"{Apiurl}/passkey", passkey); return await PutJsonAsync<UserPasskey>($"{Apiurl}/passkey", passkey);
}
public async Task<Passkey> UpdatePasskeyAsync(Passkey passkey)
{
return await PutJsonAsync<Passkey>($"{Apiurl}/passkey", passkey);
} }
public async Task DeletePasskeyAsync(byte[] credentialId) public async Task DeletePasskeyAsync(byte[] credentialId)
{ {
await DeleteAsync($"{Apiurl}/passkey?id={credentialId}"); await DeleteAsync($"{Apiurl}/passkey?id={Base64Url.EncodeToString(credentialId)}");
}
public async Task<List<UserLogin>> GetLoginsAsync()
{
return await GetJsonAsync<List<UserLogin>>($"{Apiurl}/login");
}
public async Task DeleteLoginAsync(string provider, string key)
{
await DeleteAsync($"{Apiurl}/login?provider={provider}&key={key}");
} }
} }
} }

View File

@ -417,5 +417,30 @@ namespace Oqtane.UI
return Task.CompletedTask; return Task.CompletedTask;
} }
} }
public ValueTask<string> CreateCredential(string optionsResponse)
{
try
{
return _jsRuntime.InvokeAsync<string>("Oqtane.Interop.createCredential", optionsResponse);
}
catch
{
return new ValueTask<string>(Task.FromResult(string.Empty));
}
}
public ValueTask<string> RequestCredential(string optionsResponse)
{
try
{
return _jsRuntime.InvokeAsync<string>("Oqtane.Interop.requestCredential", optionsResponse);
}
catch
{
return new ValueTask<string>(Task.FromResult(string.Empty));
}
}
} }
} }

View File

@ -1,19 +1,21 @@
using Microsoft.AspNetCore.Mvc; using System.Buffers.Text;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Oqtane.Models; using Microsoft.AspNetCore.Http;
using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc;
using System.Linq;
using System.Security.Claims;
using Oqtane.Shared;
using System.Net;
using Oqtane.Enums; using Oqtane.Enums;
using Oqtane.Extensions;
using Oqtane.Infrastructure; using Oqtane.Infrastructure;
using Oqtane.Managers;
using Oqtane.Models;
using Oqtane.Repository; using Oqtane.Repository;
using Oqtane.Security; using Oqtane.Security;
using Oqtane.Extensions; using Oqtane.Shared;
using Oqtane.Managers;
using System.Collections.Generic;
namespace Oqtane.Controllers namespace Oqtane.Controllers
{ {
@ -467,32 +469,15 @@ namespace Oqtane.Controllers
// GET: api/<controller>/passkey // GET: api/<controller>/passkey
[HttpGet("passkey")] [HttpGet("passkey")]
[Authorize] [Authorize]
public async Task<IEnumerable<Passkey>> GetPasskeys() public async Task<IEnumerable<UserPasskey>> GetPasskeys()
{ {
return await _userManager.GetPasskeys(_userPermissions.GetUser(User).UserId); return await _userManager.GetPasskeys(_userPermissions.GetUser(User).UserId);
} }
// POST api/<controller>/passkey
[HttpPost("passkey")]
[Authorize]
public async Task AddPasskey([FromBody] Passkey passkey)
{
if (ModelState.IsValid)
{
passkey.UserId = _userPermissions.GetUser(User).UserId;
await _userManager.AddPasskey(passkey);
}
else
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized User Passkey Post Attempt {PassKey}", passkey);
HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden;
}
}
// PUT api/<controller>/passkey // PUT api/<controller>/passkey
[HttpPut("passkey")] [HttpPut("passkey")]
[Authorize] [Authorize]
public async Task UpdatePasskey([FromBody] Passkey passkey) public async Task UpdatePasskey([FromBody] UserPasskey passkey)
{ {
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
@ -509,9 +494,25 @@ namespace Oqtane.Controllers
// DELETE api/<controller>/passkey?id=x // DELETE api/<controller>/passkey?id=x
[HttpDelete("passkey")] [HttpDelete("passkey")]
[Authorize] [Authorize]
public async Task DeletePasskey(byte[] id) public async Task DeletePasskey(string id)
{ {
await _userManager.DeletePasskey(_userPermissions.GetUser(User).UserId, id); await _userManager.DeletePasskey(_userPermissions.GetUser(User).UserId, Base64Url.DecodeFromChars(id));
}
// GET: api/<controller>/login
[HttpGet("login")]
[Authorize]
public async Task<IEnumerable<UserLogin>> GetLogins()
{
return await _userManager.GetLogins(_userPermissions.GetUser(User).UserId);
}
// DELETE api/<controller>/login?provider=x&key=y
[HttpDelete("login")]
[Authorize]
public async Task DeleteLogin(string provider, string key)
{
await _userManager.DeleteLogin(_userPermissions.GetUser(User).UserId, provider, key);
} }
} }
} }

View File

@ -102,7 +102,8 @@ namespace Microsoft.Extensions.DependencyInjection
}) })
.AddCookie(Constants.AuthenticationScheme) .AddCookie(Constants.AuthenticationScheme)
.AddOpenIdConnect(AuthenticationProviderTypes.OpenIDConnect, options => { }) .AddOpenIdConnect(AuthenticationProviderTypes.OpenIDConnect, options => { })
.AddOAuth(AuthenticationProviderTypes.OAuth2, options => { }); .AddOAuth(AuthenticationProviderTypes.OAuth2, options => { })
.AddTwoFactorUserIdCookie();
services.ConfigureOqtaneCookieOptions(); services.ConfigureOqtaneCookieOptions();
services.ConfigureOqtaneAuthenticationOptions(configuration); services.ConfigureOqtaneAuthenticationOptions(configuration);

View File

@ -4,9 +4,9 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Security.Policy;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Oqtane.Enums; using Oqtane.Enums;
@ -36,10 +36,11 @@ namespace Oqtane.Managers
Task<UserValidateResult> ValidateUser(string username, string email, string password); Task<UserValidateResult> ValidateUser(string username, string email, string password);
Task<bool> ValidatePassword(string password); Task<bool> ValidatePassword(string password);
Task<Dictionary<string, string>> ImportUsers(int siteId, string filePath, bool notify); Task<Dictionary<string, string>> ImportUsers(int siteId, string filePath, bool notify);
Task<List<Passkey>> GetPasskeys(int userId); Task<List<UserPasskey>> GetPasskeys(int userId);
Task AddPasskey(Passkey passkey); Task UpdatePasskey(UserPasskey passkey);
Task UpdatePasskey(Passkey passkey);
Task DeletePasskey(int userId, byte[] credentialId); Task DeletePasskey(int userId, byte[] credentialId);
Task<List<UserLogin>> GetLogins(int userId);
Task DeleteLogin(int userId, string provider, string key);
} }
public class UserManager : IUserManager public class UserManager : IUserManager
@ -824,9 +825,9 @@ namespace Oqtane.Managers
return result; return result;
} }
public async Task<List<Passkey>> GetPasskeys(int userId) public async Task<List<UserPasskey>> GetPasskeys(int userId)
{ {
var passkeys = new List<Passkey>(); var passkeys = new List<UserPasskey>();
var user = _users.GetUser(userId); var user = _users.GetUser(userId);
if (user != null) if (user != null)
{ {
@ -836,31 +837,14 @@ namespace Oqtane.Managers
var userpasskeys = await _identityUserManager.GetPasskeysAsync(identityuser); var userpasskeys = await _identityUserManager.GetPasskeysAsync(identityuser);
foreach (var userpasskey in userpasskeys) foreach (var userpasskey in userpasskeys)
{ {
passkeys.Add(new Passkey { CredentialId = userpasskey.CredentialId, Name = userpasskey.Name, UserId = userId }); passkeys.Add(new UserPasskey { CredentialId = userpasskey.CredentialId, Name = userpasskey.Name, UserId = userId });
} }
} }
} }
return passkeys; return passkeys;
} }
public async Task AddPasskey(Passkey passkey) public async Task UpdatePasskey(UserPasskey passkey)
{
var user = _users.GetUser(passkey.UserId);
if (user != null)
{
var identityuser = await _identityUserManager.FindByNameAsync(user.Username);
if (identityuser != null)
{
var attestationResult = await _identitySignInManager.PerformPasskeyAttestationAsync(passkey.CredentialJson);
if (attestationResult.Succeeded)
{
var addPasskeyResult = await _identityUserManager.AddOrUpdatePasskeyAsync(identityuser, attestationResult.Passkey);
}
}
}
}
public async Task UpdatePasskey(Passkey passkey)
{ {
var user = _users.GetUser(passkey.UserId); var user = _users.GetUser(passkey.UserId);
if (user != null) if (user != null)
@ -890,5 +874,37 @@ namespace Oqtane.Managers
} }
} }
} }
public async Task<List<UserLogin>> GetLogins(int userId)
{
var logins = new List<UserLogin>();
var user = _users.GetUser(userId);
if (user != null)
{
var identityuser = await _identityUserManager.FindByNameAsync(user.Username);
if (identityuser != null)
{
var userlogins = await _identityUserManager.GetLoginsAsync(identityuser);
foreach (var userlogin in userlogins)
{
logins.Add(new UserLogin { Provider = userlogin.LoginProvider, Key = userlogin.ProviderKey, Name = userlogin.ProviderDisplayName });
}
}
}
return logins;
}
public async Task DeleteLogin(int userId, string provider, string key)
{
var user = _users.GetUser(userId);
if (user != null)
{
var identityuser = await _identityUserManager.FindByNameAsync(user.Username);
if (identityuser != null)
{
await _identityUserManager.RemoveLoginAsync(identityuser, provider, key);
}
}
}
} }
} }

View File

@ -0,0 +1,3 @@
@page "/pages/passkey"
@namespace Oqtane.Pages
@model Oqtane.Pages.PasskeyModel

View File

@ -0,0 +1,144 @@
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.Security;
using Oqtane.Shared;
namespace Oqtane.Pages
{
[AllowAnonymous]
public class PasskeyModel : PageModel
{
private readonly UserManager<IdentityUser> _identityUserManager;
private readonly SignInManager<IdentityUser> _identitySignInManager;
private readonly IUserManager _userManager;
private readonly ILogManager _logger;
public PasskeyModel(UserManager<IdentityUser> identityUserManager, SignInManager<IdentityUser> identitySignInManager, IUserManager userManager, ILogManager logger)
{
_identityUserManager = identityUserManager;
_identitySignInManager = identitySignInManager;
_userManager = userManager;
_logger = logger;
}
public async Task<IActionResult> OnPostAsync(string operation, string credential, string returnurl)
{
if (HttpContext.GetSiteSettings().GetValue("LoginOptions:Passkeys", "false") == "true")
{
IdentityUser identityuser;
switch (operation.ToLower())
{
case "create":
if (User.Identity.IsAuthenticated)
{
identityuser = await _identityUserManager.FindByNameAsync(User.Identity.Name);
if (identityuser != null)
{
var creationOptionsJson = await _identitySignInManager.MakePasskeyCreationOptionsAsync(new()
{
Id = identityuser.Id,
Name = identityuser.UserName,
DisplayName = identityuser.UserName
});
returnurl += $"?options={WebUtility.UrlEncode(creationOptionsJson)}";
}
else
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Create Attempt - User {User} Does Not Exist", User.Identity.Name);
}
}
else
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Create Attempt - User Not Authenticated");
}
break;
case "validate":
if (User.Identity.IsAuthenticated && !string.IsNullOrEmpty(credential))
{
identityuser = await _identityUserManager.FindByNameAsync(User.Identity.Name);
if (identityuser != null)
{
var attestationResult = await _identitySignInManager.PerformPasskeyAttestationAsync(credential);
if (attestationResult.Succeeded)
{
attestationResult.Passkey.Name = identityuser.UserName + "'s Passkey";
var addPasskeyResult = await _identityUserManager.AddOrUpdatePasskeyAsync(identityuser, attestationResult.Passkey);
}
else
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Passkey Validation Failed For User {Username} - {Message}", User.Identity.Name, attestationResult.Failure.Message);
}
}
else
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Validation Attempt - User {User} Does Not Exist", User.Identity.Name);
}
}
else
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Validation Attempt - User Not Authenticated Or Credential Not Provided");
}
break;
case "request":
if (!User.Identity.IsAuthenticated)
{
identityuser = null;
var requestOptionsJson = await _identitySignInManager.MakePasskeyRequestOptionsAsync(identityuser);
returnurl += $"?options={WebUtility.UrlEncode(requestOptionsJson)}";
}
else
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Request Attempt - User Is Already Authenticated");
}
break;
case "login":
if (!User.Identity.IsAuthenticated && !string.IsNullOrEmpty(credential))
{
var result = await _identitySignInManager.PasskeySignInAsync(credential);
if (result.Succeeded)
{
var user = _userManager.GetUser(User.Identity.Name, HttpContext.GetAlias().SiteId);
if (user != null && !user.IsDeleted && UserSecurity.ContainsRole(user.Roles, RoleNames.Registered))
{
_logger.Log(LogLevel.Information, this, LogFunction.Security, "Passkey Login Successful For User {Username}", User.Identity.Name);
}
else
{
_logger.Log(LogLevel.Information, this, LogFunction.Security, "Passkey Login Failed For User {Username}", User.Identity.Name);
}
}
else
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Passkey Login Failed - Invalid Credential");
}
}
else
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Login Attempt");
}
break;
}
}
else
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Passkey Request - Passkeys Are Not Enabled For Site");
}
if (!returnurl.StartsWith("/"))
{
returnurl = "/" + returnurl;
}
return LocalRedirect(Url.Content("~" + returnurl));
}
}
}

View File

@ -1,5 +1,5 @@
/* Login Module Custom Styles */ /* Login Module Custom Styles */
.Oqtane-Modules-Admin-Login { .Oqtane-Modules-Admin-Login {
width: 200px; width: 220px;
} }

View File

@ -516,5 +516,17 @@ Oqtane.Interop = {
} }
} }
} }
},
createCredential: async function (optionsResponse) {
const optionsJson = JSON.parse(optionsResponse);
const options = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJson);
const credential = await navigator.credentials.create({ publicKey: options });
return JSON.stringify(credential);
},
requestCredential: async function (optionsResponse) {
const optionsJson = JSON.parse(optionsResponse);
const options = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJson);
const credential = await navigator.credentials.get({ publicKey: options, undefined });
return JSON.stringify(credential);
} }
}; };

View File

@ -0,0 +1,23 @@
namespace Oqtane.Models
{
/// <summary>
/// Passkey properties
/// </summary>
public class UserLogin
{
/// <summary>
/// the login provider for this login
/// </summary>
public string Provider { get; set; }
/// <summary>
/// The key for this login
/// </summary>
public string Key { get; set; }
/// <summary>
/// The friendly name for the login provider
/// </summary>
public string Name { get; set; }
}
}

View File

@ -3,7 +3,7 @@ namespace Oqtane.Models
/// <summary> /// <summary>
/// Passkey properties /// Passkey properties
/// </summary> /// </summary>
public class Passkey public class UserPasskey
{ {
/// <summary> /// <summary>
/// the credential ID for this passkey /// the credential ID for this passkey