add passkey functionality
This commit is contained in:
@ -20,39 +20,50 @@ else
|
||||
<div class="Oqtane-Modules-Admin-Login" @onkeypress="@(e => KeyPressed(e))">
|
||||
@if (_allowexternallogin)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="ExternalLogin">@Localizer["Use"] @PageState.Site.Settings["ExternalLogin:ProviderName"]</button>
|
||||
<br /><br />
|
||||
<button type="button" class="btn btn-primary col-12" @onclick="ExternalLogin">@Localizer["Use"] @PageState.Site.Settings["ExternalLogin:ProviderName"]</button>
|
||||
<hr class="app-rule mt-3 mb-2" />
|
||||
}
|
||||
@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>
|
||||
<input id="username" type="text" @ref="username" class="form-control" placeholder="@Localizer["Username.Placeholder"]" @bind="@_username" @bind:event="oninput" required />
|
||||
</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>
|
||||
<div class="input-group">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group mt-2">
|
||||
@if (!_alwaysremember)
|
||||
{
|
||||
<div class="form-check">
|
||||
@if (!_alwaysremember)
|
||||
{
|
||||
<div class="form-group text-center mt-2">
|
||||
<div>
|
||||
<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>
|
||||
</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>
|
||||
<button type="button" class="btn btn-primary" @onclick="Login">@SharedLocalizer["Login"]</button>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button>
|
||||
<br /><br />
|
||||
<button type="button" class="btn btn-secondary" @onclick="Forgot">@Localizer["ForgotPassword"]</button>
|
||||
@if (PageState.Site.AllowRegistration)
|
||||
<button type="button" class="btn btn-secondary col-12 mt-4" @onclick="Forgot">@Localizer["ForgotPassword"]</button>
|
||||
|
||||
@if (_allowpasskeys)
|
||||
{
|
||||
<br /><br />
|
||||
<NavLink href="@NavigateUrl("register")">@Localizer["Register"]</NavLink>
|
||||
<hr class="app-rule mt-3" />
|
||||
<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>
|
||||
@ -77,6 +88,7 @@ else
|
||||
@code {
|
||||
private bool _allowsitelogin = true;
|
||||
private bool _allowexternallogin = false;
|
||||
private bool _allowpasskeys = false;
|
||||
private ElementReference login;
|
||||
private bool validated = false;
|
||||
private bool twofactor = false;
|
||||
@ -88,6 +100,7 @@ else
|
||||
private bool _remember = false;
|
||||
private bool _alwaysremember = false;
|
||||
private string _code = string.Empty;
|
||||
private string _registerurl = string.Empty;
|
||||
|
||||
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Anonymous;
|
||||
public override bool? Prerender => true;
|
||||
@ -103,8 +116,18 @@ else
|
||||
{
|
||||
_allowexternallogin = (SettingService.GetSetting(PageState.Site.Settings, "ExternalLogin:ProviderType", "") != "") ? true : false;
|
||||
_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"));
|
||||
|
||||
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"];
|
||||
|
||||
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()
|
||||
{
|
||||
try
|
||||
@ -331,4 +337,58 @@ else
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
<br />
|
||||
}
|
||||
<TabStrip>
|
||||
<TabPanel Name="Identity" ResourceKey="Identity">
|
||||
<TabPanel Name="Identity" Heading="Identity" ResourceKey="Identity">
|
||||
<div class="container">
|
||||
<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>
|
||||
@ -69,7 +69,7 @@
|
||||
<button type="button" class="btn btn-success" @onclick="Save">@SharedLocalizer["Save"]</button>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button>
|
||||
</TabPanel>
|
||||
<TabPanel Name="Security" ResourceKey="Security">
|
||||
<TabPanel Name="Security" Heading="Security" ResourceKey="Security">
|
||||
<ModuleMessage Message="@_passwordrequirements" Type="MessageType.Info" />
|
||||
<div class="container">
|
||||
<div class="row mb-1 align-items-center">
|
||||
@ -110,40 +110,70 @@
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<br />
|
||||
}
|
||||
<Section Name="External" Heading="External Login" ResourceKey="External">
|
||||
</Section>
|
||||
<Section Name="Passkeys" Heading="Passkeys" ResourceKey="Passkeys">
|
||||
<button type="button" class="btn btn-primary" @onclick="AddPasskey">@SharedLocalizer["Add"]</button>
|
||||
<Pager Items="@_passkeys">
|
||||
<Header>
|
||||
<th style="width: 1px;"> </th>
|
||||
<th style="width: 1px;"> </th>
|
||||
<th>@Localizer["Passkey"]</th>
|
||||
</Header>
|
||||
<Row>
|
||||
@if (context.CredentialId != _passkeyId)
|
||||
{
|
||||
<td><button type="button" class="btn btn-primary" @onclick="@(() => EditPasskey(context))">@SharedLocalizer["Edit"]</button></td>
|
||||
<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>
|
||||
<td>@context.Name</td>
|
||||
}
|
||||
else
|
||||
{
|
||||
<td><button type="button" class="btn btn-success" @onclick="@(async () => await SavePasskey())">@SharedLocalizer["Save"]</button></td>
|
||||
<td><button type="button" class="btn btn-secondary" @onclick="@(async () => await CancelPasskey())">@SharedLocalizer["Cancel"]</button></td>
|
||||
<td><input id="aliasname" class="form-control" @bind="@_passkeyName" /></td>
|
||||
}
|
||||
</Row>
|
||||
</Pager>
|
||||
<br /><br />
|
||||
</Section>
|
||||
<Section Name="Logout" Heading="Logout" ResourceKey="Logout">
|
||||
<button type="button" class="btn btn-danger" @onclick="Logout">@Localizer["Logout Everywhere"]</button>
|
||||
</Section>
|
||||
@if (_allowpasskeys)
|
||||
{
|
||||
<Section Name="Passkeys" Heading="Passkeys" ResourceKey="Passkeys">
|
||||
@if (PageState.Route.Scheme == "https")
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="AddPasskey">@SharedLocalizer["Add"]</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ModuleMessage Type="MessageType.Warning" Message="@Localizer["Message.Passkey.Insecure"]" />
|
||||
}
|
||||
@if (_passkeys != null && _passkeys.Count > 0)
|
||||
{
|
||||
<Pager Items="@_passkeys">
|
||||
<Header>
|
||||
<th style="width: 1px;"> </th>
|
||||
<th style="width: 1px;"> </th>
|
||||
<th>@Localizer["Passkey"]</th>
|
||||
</Header>
|
||||
<Row>
|
||||
@if (context.CredentialId != _passkeyId)
|
||||
{
|
||||
<td><button type="button" class="btn btn-primary" @onclick="@(() => EditPasskey(context))">@SharedLocalizer["Edit"]</button></td>
|
||||
<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>
|
||||
<td>@context.Name</td>
|
||||
}
|
||||
else
|
||||
{
|
||||
<td><button type="button" class="btn btn-success" @onclick="@(async () => await SavePasskey())">@SharedLocalizer["Save"]</button></td>
|
||||
<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;"> </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 />
|
||||
</TabPanel>
|
||||
<TabPanel Name="Profile" ResourceKey="Profile">
|
||||
<TabPanel Name="Profile" Heading="Profile" ResourceKey="Profile">
|
||||
<div class="container">
|
||||
<div class="row mb-1 align-items-center">
|
||||
@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-secondary" @onclick="Cancel">@SharedLocalizer["Cancel"]</button>
|
||||
</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")" />
|
||||
<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="from">@Localizer["Items.Sent"]</option>
|
||||
</select>
|
||||
@ -295,7 +325,7 @@
|
||||
</Header>
|
||||
<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><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)
|
||||
{
|
||||
@ -358,7 +388,7 @@
|
||||
</Header>
|
||||
<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><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)
|
||||
{
|
||||
@ -415,15 +445,14 @@
|
||||
}
|
||||
|
||||
@code {
|
||||
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.View;
|
||||
|
||||
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 string _twofactor = "False";
|
||||
private bool _allowpasskeys = false;
|
||||
private bool _allowexternallogin = false;
|
||||
|
||||
private string _username = string.Empty;
|
||||
private string _email = string.Empty;
|
||||
private string _displayname = string.Empty;
|
||||
private FileManager _filemanager;
|
||||
@ -434,9 +463,16 @@
|
||||
private File _photo = null;
|
||||
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 string _passkeyName = string.Empty;
|
||||
private List<UserLogin> _logins;
|
||||
|
||||
private List<Profile> _profiles;
|
||||
private Dictionary<string, string> _userSettings;
|
||||
@ -446,45 +482,29 @@
|
||||
private List<Notification> _notifications;
|
||||
private string _notificationSummary = string.Empty;
|
||||
|
||||
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.View;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_passwordrequirements = await UserService.GetPasswordRequirementsAsync(PageState.Site.SiteId);
|
||||
_togglepassword = SharedLocalizer["ShowPassword"];
|
||||
_allowtwofactor = (SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:TwoFactor", "false") == "true");
|
||||
_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}"));
|
||||
}
|
||||
}
|
||||
_timezones = TimeZoneService.GetTimeZones();
|
||||
_allowpasskeys = (SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:Passkeys", "false") == "true");
|
||||
_allowexternallogin = (SettingService.GetSetting(PageState.Site.Settings, "ExternalLogin:ProviderType", "") != "") ? true : false;
|
||||
|
||||
if (PageState.User != null)
|
||||
{
|
||||
// identity section
|
||||
_username = PageState.User.Username;
|
||||
_twofactor = PageState.User.TwoFactorRequired.ToString();
|
||||
_email = PageState.User.Email;
|
||||
_displayname = PageState.User.DisplayName;
|
||||
_timezoneid = PageState.User.TimeZoneId;
|
||||
|
||||
// get user folder
|
||||
_timezones = TimeZoneService.GetTimeZones();
|
||||
_timezoneid = PageState.User.TimeZoneId;
|
||||
var folder = await FolderService.GetFolderAsync(ModuleState.SiteId, PageState.User.FolderPath);
|
||||
if (folder != null)
|
||||
{
|
||||
_folderid = folder.FolderId;
|
||||
}
|
||||
|
||||
_imagefiles = SettingService.GetSetting(PageState.Site.Settings, "ImageFiles", Constants.ImageFiles);
|
||||
_imagefiles = (string.IsNullOrEmpty(_imagefiles)) ? Constants.ImageFiles : _imagefiles;
|
||||
|
||||
if (PageState.User.PhotoFileId != null)
|
||||
{
|
||||
_photofileid = PageState.User.PhotoFileId.Value;
|
||||
@ -496,8 +516,27 @@
|
||||
_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;
|
||||
|
||||
// notification section
|
||||
await LoadNotificationsAsync();
|
||||
|
||||
_initialized = true;
|
||||
@ -514,22 +553,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadNotificationsAsync()
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
// identity methods
|
||||
private async Task Save()
|
||||
{
|
||||
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()
|
||||
{
|
||||
await logger.LogInformation("User Logout Everywhere For Username {Username}", PageState.User?.Username);
|
||||
@ -630,51 +772,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GetPasskeys()
|
||||
{
|
||||
_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();
|
||||
}
|
||||
// profile methods
|
||||
|
||||
private void EditPasskey(Passkey passkey)
|
||||
private string GetProfileValue(string SettingName, string DefaultValue)
|
||||
{
|
||||
_passkeyId = passkey.CredentialId;
|
||||
_passkeyName = passkey.Name;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task DeletePasskey(Passkey passkey)
|
||||
{
|
||||
await UserService.DeletePasskeyAsync(passkey.CredentialId);
|
||||
await GetPasskeys();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task SavePasskey()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_passkeyName))
|
||||
string value = SettingService.GetSetting(_userSettings, SettingName, DefaultValue);
|
||||
if (value.Contains("]"))
|
||||
{
|
||||
await UserService.UpdatePasskeyAsync(new Passkey { CredentialId = _passkeyId, Name = _passkeyName });
|
||||
await GetPasskeys();
|
||||
_passkeyName = "";
|
||||
StateHasChanged();
|
||||
value = value.Substring(value.IndexOf("]") + 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private async Task CancelPasskey()
|
||||
private void ProfileChanged(ChangeEventArgs e, string SettingName)
|
||||
{
|
||||
await GetPasskeys();
|
||||
_passkeyName = "";
|
||||
StateHasChanged();
|
||||
var value = (string)e.Value;
|
||||
_userSettings = SettingService.SetSetting(_userSettings, SettingName, value);
|
||||
}
|
||||
|
||||
|
||||
private bool ValidateProfiles()
|
||||
{
|
||||
foreach (Profile profile in _profiles)
|
||||
@ -706,18 +821,22 @@
|
||||
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;
|
||||
_userSettings = SettingService.SetSetting(_userSettings, SettingName, value);
|
||||
_filter = (string)e.Value;
|
||||
await LoadNotificationsAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task Delete(Notification Notification)
|
||||
private async Task DeleteNotification(Notification Notification)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -742,13 +861,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async void FilterChanged(ChangeEventArgs e)
|
||||
{
|
||||
_filter = (string)e.Value;
|
||||
await LoadNotificationsAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task DeleteAllNotifications()
|
||||
{
|
||||
try
|
||||
@ -780,18 +892,4 @@
|
||||
HideProgressIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
private void TogglePassword()
|
||||
{
|
||||
if (_passwordtype == "password")
|
||||
{
|
||||
_passwordtype = "text";
|
||||
_togglepassword = SharedLocalizer["HidePassword"];
|
||||
}
|
||||
else
|
||||
{
|
||||
_passwordtype = "password";
|
||||
_togglepassword = SharedLocalizer["ShowPassword"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,32 +72,41 @@ else
|
||||
</select>
|
||||
</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 (_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">
|
||||
<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">
|
||||
@ -538,6 +547,7 @@ else
|
||||
private string _registerurl;
|
||||
private string _profileurl;
|
||||
private string _requireconfirmedemail;
|
||||
private string _passkeys;
|
||||
private string _twofactor;
|
||||
private string _cookiename;
|
||||
private string _cookiedomain;
|
||||
@ -609,12 +619,13 @@ else
|
||||
|
||||
var settings = await SettingService.GetSiteSettingsAsync(PageState.Site.SiteId);
|
||||
_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))
|
||||
{
|
||||
_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");
|
||||
_cookiename = SettingService.GetSetting(settings, "LoginOptions:CookieName", ".AspNetCore.Identity.Application");
|
||||
_cookiedomain = SettingService.GetSetting(settings, "LoginOptions:CookieDomain", "");
|
||||
@ -753,6 +764,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:Passkeys", _passkeys, false);
|
||||
settings = SettingService.SetSetting(settings, "LoginOptions:TwoFactor", _twofactor, false);
|
||||
settings = SettingService.SetSetting(settings, "LoginOptions:CookieName", _cookiename, true);
|
||||
settings = SettingService.SetSetting(settings, "LoginOptions:CookieDomain", _cookiedomain, true);
|
||||
|
||||
@ -118,7 +118,7 @@
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ForgotPassword" xml:space="preserve">
|
||||
<value>Forgot Password</value>
|
||||
<value>Forgot Password?</value>
|
||||
</data>
|
||||
<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>
|
||||
@ -231,4 +231,7 @@
|
||||
<data name="Register" xml:space="preserve">
|
||||
<value>Register as new user?</value>
|
||||
</data>
|
||||
<data name="Passkey" xml:space="preserve">
|
||||
<value>Use Passkey</value>
|
||||
</data>
|
||||
</root>
|
||||
@ -168,9 +168,6 @@
|
||||
<data name="DeleteNotification.Message" xml:space="preserve">
|
||||
<value>Are You Sure You Wish To Delete This Notification?</value>
|
||||
</data>
|
||||
<data name="Identity.Name" xml:space="preserve">
|
||||
<value>Identity</value>
|
||||
</data>
|
||||
<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>
|
||||
</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>
|
||||
</data>
|
||||
<data name="TwoFactor.Text" xml:space="preserve">
|
||||
<value>Two Factor?</value>
|
||||
<value>Use Two Factor?</value>
|
||||
</data>
|
||||
<data name="DeleteAllNotifications.Header" xml:space="preserve">
|
||||
<value>Clear Notifications</value>
|
||||
@ -249,4 +246,40 @@
|
||||
<data name="TimeZone.HelpText" xml:space="preserve">
|
||||
<value>Your time zone</value>
|
||||
</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>
|
||||
@ -561,4 +561,10 @@
|
||||
<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>
|
||||
</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>
|
||||
@ -3,12 +3,14 @@ using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Oqtane.Enums;
|
||||
using Oqtane.Models;
|
||||
using Oqtane.Shared;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
|
||||
namespace Oqtane.Services
|
||||
{
|
||||
@ -206,6 +208,17 @@ namespace Oqtane.Services
|
||||
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)
|
||||
{
|
||||
return await PostJsonAsync<T, T>(uri, value);
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
using Oqtane.Shared;
|
||||
using Oqtane.Models;
|
||||
using System.Buffers.Text;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Oqtane.Documentation;
|
||||
using System.Net;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Oqtane.Documentation;
|
||||
using Oqtane.Models;
|
||||
using Oqtane.Shared;
|
||||
|
||||
namespace Oqtane.Services
|
||||
{
|
||||
@ -177,21 +178,14 @@ namespace Oqtane.Services
|
||||
/// Get passkeys for a user
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
Task<List<Passkey>> GetPasskeysAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Add a user passkey
|
||||
/// </summary>
|
||||
/// <param name="passkey"></param>
|
||||
/// <returns></returns>
|
||||
Task<Passkey> AddPasskeyAsync(Passkey passkey);
|
||||
Task<List<UserPasskey>> GetPasskeysAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Update a user passkey
|
||||
/// </summary>
|
||||
/// <param name="passkey"></param>
|
||||
/// <returns></returns>
|
||||
Task<Passkey> UpdatePasskeyAsync(Passkey passkey);
|
||||
Task<UserPasskey> UpdatePasskeyAsync(UserPasskey passkey);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a user passkey
|
||||
@ -199,6 +193,20 @@ namespace Oqtane.Services
|
||||
/// <param name="credentialId"></param>
|
||||
/// <returns></returns>
|
||||
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")]
|
||||
@ -330,24 +338,29 @@ namespace Oqtane.Services
|
||||
return await PostJsonAsync<Dictionary<string, string>>($"{Apiurl}/import?siteid={siteId}&fileid={fileId}¬ify={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);
|
||||
}
|
||||
|
||||
public async Task<Passkey> UpdatePasskeyAsync(Passkey passkey)
|
||||
{
|
||||
return await PutJsonAsync<Passkey>($"{Apiurl}/passkey", passkey);
|
||||
return await PutJsonAsync<UserPasskey>($"{Apiurl}/passkey", passkey);
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -417,5 +417,30 @@ namespace Oqtane.UI
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.Authorization;
|
||||
using Oqtane.Models;
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using Oqtane.Shared;
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Oqtane.Enums;
|
||||
using Oqtane.Extensions;
|
||||
using Oqtane.Infrastructure;
|
||||
using Oqtane.Managers;
|
||||
using Oqtane.Models;
|
||||
using Oqtane.Repository;
|
||||
using Oqtane.Security;
|
||||
using Oqtane.Extensions;
|
||||
using Oqtane.Managers;
|
||||
using System.Collections.Generic;
|
||||
using Oqtane.Shared;
|
||||
|
||||
namespace Oqtane.Controllers
|
||||
{
|
||||
@ -467,32 +469,15 @@ namespace Oqtane.Controllers
|
||||
// GET: api/<controller>/passkey
|
||||
[HttpGet("passkey")]
|
||||
[Authorize]
|
||||
public async Task<IEnumerable<Passkey>> GetPasskeys()
|
||||
public async Task<IEnumerable<UserPasskey>> GetPasskeys()
|
||||
{
|
||||
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
|
||||
[HttpPut("passkey")]
|
||||
[Authorize]
|
||||
public async Task UpdatePasskey([FromBody] Passkey passkey)
|
||||
public async Task UpdatePasskey([FromBody] UserPasskey passkey)
|
||||
{
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
@ -509,9 +494,25 @@ namespace Oqtane.Controllers
|
||||
// DELETE api/<controller>/passkey?id=x
|
||||
[HttpDelete("passkey")]
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,7 +102,8 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
})
|
||||
.AddCookie(Constants.AuthenticationScheme)
|
||||
.AddOpenIdConnect(AuthenticationProviderTypes.OpenIDConnect, options => { })
|
||||
.AddOAuth(AuthenticationProviderTypes.OAuth2, options => { });
|
||||
.AddOAuth(AuthenticationProviderTypes.OAuth2, options => { })
|
||||
.AddTwoFactorUserIdCookie();
|
||||
|
||||
services.ConfigureOqtaneCookieOptions();
|
||||
services.ConfigureOqtaneAuthenticationOptions(configuration);
|
||||
|
||||
@ -4,9 +4,9 @@ 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;
|
||||
@ -36,10 +36,11 @@ namespace Oqtane.Managers
|
||||
Task<UserValidateResult> ValidateUser(string username, string email, string password);
|
||||
Task<bool> ValidatePassword(string password);
|
||||
Task<Dictionary<string, string>> ImportUsers(int siteId, string filePath, bool notify);
|
||||
Task<List<Passkey>> GetPasskeys(int userId);
|
||||
Task AddPasskey(Passkey passkey);
|
||||
Task UpdatePasskey(Passkey passkey);
|
||||
Task<List<UserPasskey>> GetPasskeys(int userId);
|
||||
Task UpdatePasskey(UserPasskey passkey);
|
||||
Task DeletePasskey(int userId, byte[] credentialId);
|
||||
Task<List<UserLogin>> GetLogins(int userId);
|
||||
Task DeleteLogin(int userId, string provider, string key);
|
||||
}
|
||||
|
||||
public class UserManager : IUserManager
|
||||
@ -824,9 +825,9 @@ namespace Oqtane.Managers
|
||||
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);
|
||||
if (user != null)
|
||||
{
|
||||
@ -836,31 +837,14 @@ namespace Oqtane.Managers
|
||||
var userpasskeys = await _identityUserManager.GetPasskeysAsync(identityuser);
|
||||
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;
|
||||
}
|
||||
|
||||
public async Task AddPasskey(Passkey 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)
|
||||
public async Task UpdatePasskey(UserPasskey passkey)
|
||||
{
|
||||
var user = _users.GetUser(passkey.UserId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
Oqtane.Server/Pages/Passkey.cshtml
Normal file
3
Oqtane.Server/Pages/Passkey.cshtml
Normal file
@ -0,0 +1,3 @@
|
||||
@page "/pages/passkey"
|
||||
@namespace Oqtane.Pages
|
||||
@model Oqtane.Pages.PasskeyModel
|
||||
144
Oqtane.Server/Pages/Passkey.cshtml.cs
Normal file
144
Oqtane.Server/Pages/Passkey.cshtml.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
/* Login Module Custom Styles */
|
||||
|
||||
.Oqtane-Modules-Admin-Login {
|
||||
width: 200px;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
23
Oqtane.Shared/Models/UserLogin.cs
Normal file
23
Oqtane.Shared/Models/UserLogin.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@ namespace Oqtane.Models
|
||||
/// <summary>
|
||||
/// Passkey properties
|
||||
/// </summary>
|
||||
public class Passkey
|
||||
public class UserPasskey
|
||||
{
|
||||
/// <summary>
|
||||
/// the credential ID for this passkey
|
||||
Reference in New Issue
Block a user