Added support for per site options and OpenID Connect

This commit is contained in:
Shaun Walker
2022-03-13 22:55:52 -04:00
parent a47ecbdea9
commit 9bbbff31f8
31 changed files with 1064 additions and 180 deletions

View File

@ -51,14 +51,6 @@ namespace Oqtane.Controllers
systeminfo.Add("Logging:LogLevel:Notify", _configManager.GetSetting("Logging:LogLevel:Notify", "Error"));
systeminfo.Add("UseSwagger", _configManager.GetSetting("UseSwagger", "true"));
systeminfo.Add("PackageService", _configManager.GetSetting("PackageService", "true"));
systeminfo.Add("Password:RequiredLength", _configManager.GetSetting("Password:RequiredLength", "6"));
systeminfo.Add("Password:RequiredUniqueChars", _configManager.GetSetting("Password:RequiredUniqueChars", "1"));
systeminfo.Add("Password:RequireDigit", _configManager.GetSetting("Password:RequireDigit", "true"));
systeminfo.Add("Password:RequireUppercase", _configManager.GetSetting("Password:RequireUppercase", "true"));
systeminfo.Add("Password:RequireLowercase", _configManager.GetSetting("Password:RequireLowercase", "true"));
systeminfo.Add("Password:RequireNonAlphanumeric", _configManager.GetSetting("Password:RequireNonAlphanumeric", "true"));
systeminfo.Add("Lockout:MaxFailedAccessAttempts", _configManager.GetSetting("Lockout:MaxFailedAccessAttempts", "5"));
systeminfo.Add("Lockout:DefaultLockoutTimeSpan", _configManager.GetSetting("Lockout:DefaultLockoutTimeSpan", "00:05:00"));
break;
}

View File

@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Http;
using Oqtane.Models;
using Oqtane.Shared;
namespace Oqtane.Extensions
{
public static class HttpContextExtensions
{
public static Alias GetAlias(this HttpContext context)
{
if (context != null && context.Items.ContainsKey(Constants.HttpContextAliasKey))
{
return context.Items[Constants.HttpContextAliasKey] as Alias;
}
return null;
}
}
}

View File

@ -15,6 +15,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.OpenApi.Models;
using Oqtane.Infrastructure;
using Oqtane.Models;
using Oqtane.Modules;
using Oqtane.Repository;
using Oqtane.Security;
@ -58,6 +59,12 @@ namespace Microsoft.Extensions.DependencyInjection
return services;
}
public static OqtaneSiteOptionsBuilder<T> AddOqtaneSiteOptions<T>(this IServiceCollection services)
where T : class, IAlias, new()
{
return new OqtaneSiteOptionsBuilder<T>(services);
}
internal static IServiceCollection AddOqtaneSingletonServices(this IServiceCollection services)
{
services.AddSingleton<IInstallationManager, InstallationManager>();
@ -71,6 +78,8 @@ namespace Microsoft.Extensions.DependencyInjection
internal static IServiceCollection AddOqtaneTransientServices(this IServiceCollection services)
{
services.AddTransient<ITenantManager, TenantManager>();
services.AddTransient<IAliasAccessor, AliasAccessor>();
services.AddTransient<IModuleDefinitionRepository, ModuleDefinitionRepository>();
services.AddTransient<IThemeRepository, ThemeRepository>();
services.AddTransient<IUserPermissions, UserPermissions>();
@ -124,6 +133,11 @@ namespace Microsoft.Extensions.DependencyInjection
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
return Task.CompletedTask;
};
options.Events.OnRedirectToLogout = context =>
{
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
return Task.CompletedTask;
};
options.Events.OnValidatePrincipal = PrincipalValidator.ValidateAsync;
});
@ -314,7 +328,7 @@ namespace Microsoft.Extensions.DependencyInjection
try
{
Assembly assembly = AssemblyLoadContext.Default.LoadFromStream(new MemoryStream(File.ReadAllBytes(assemblyFile.FullName)));
Assembly assembly = AssemblyLoadContext.Default.LoadFromStream(new MemoryStream(System.IO.File.ReadAllBytes(assemblyFile.FullName)));
Debug.WriteLine($"Oqtane Info: Loaded Assembly {assemblyName}");
}
catch (Exception ex)
@ -333,9 +347,9 @@ namespace Microsoft.Extensions.DependencyInjection
private static Assembly ResolveDependencies(AssemblyLoadContext context, AssemblyName name)
{
var assemblyPath = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location) + Path.DirectorySeparatorChar + name.Name + ".dll";
if (File.Exists(assemblyPath))
if (System.IO.File.Exists(assemblyPath))
{
return context.LoadFromStream(new MemoryStream(File.ReadAllBytes(assemblyPath)));
return context.LoadFromStream(new MemoryStream(System.IO.File.ReadAllBytes(assemblyPath)));
}
else
{

View File

@ -0,0 +1,233 @@
using System;
using System.Linq;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Oqtane.Infrastructure;
using Oqtane.Models;
using Oqtane.Shared;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Oqtane.Repository;
using System.IO;
using System.Collections.Generic;
using Oqtane.Security;
namespace Oqtane.Extensions
{
public static class OqtaneSiteAuthenticationBuilderExtensions
{
public static OqtaneSiteOptionsBuilder<TAlias> WithSiteAuthentication<TAlias>(
this OqtaneSiteOptionsBuilder<TAlias> builder)
where TAlias : class, IAlias, new()
{
builder.WithSiteAuthenticationCore();
builder.WithSiteAuthenticationOptions();
return builder;
}
public static OqtaneSiteOptionsBuilder<TAlias> WithSiteAuthenticationCore<TAlias>(
this OqtaneSiteOptionsBuilder<TAlias> builder)
where TAlias : class, IAlias, new()
{
builder.Services.DecorateService<IAuthenticationService, SiteAuthenticationService<TAlias>>();
builder.Services.Replace(ServiceDescriptor.Singleton<IAuthenticationSchemeProvider, SiteAuthenticationSchemeProvider>());
return builder;
}
public static OqtaneSiteOptionsBuilder<TAlias> WithSiteAuthenticationOptions<TAlias>(
this OqtaneSiteOptionsBuilder<TAlias> builder)
where TAlias : class, IAlias, new()
{
// site OpenIdConnect options
builder.AddSiteOptions<OpenIdConnectOptions>((options, alias) =>
{
if (alias.SiteSettings.ContainsKey("OpenIdConnectOptions:Authority"))
{
options.Authority = alias.SiteSettings["OpenIdConnectOptions:Authority"];
}
if (alias.SiteSettings.ContainsKey("OpenIdConnectOptions:ClientId"))
{
options.ClientId = alias.SiteSettings["OpenIdConnectOptions:ClientId"];
}
if (alias.SiteSettings.ContainsKey("OpenIdConnectOptions:ClientSecret"))
{
options.ClientSecret = alias.SiteSettings["OpenIdConnectOptions:ClientSecret"];
}
// default options
options.SignInScheme = Constants.AuthenticationScheme; // identity cookie
options.RequireHttpsMetadata = true;
options.UsePkce = true;
options.Scope.Add("openid"); // core claims
options.Scope.Add("profile"); // name claims
options.Scope.Add("email"); // email claim
//options.Scope.Add("offline_access"); // get refresh token
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.CallbackPath = string.IsNullOrEmpty(alias.Path) ? "/signin-oidc" : "/" + alias.Path + "/signin-oidc";
options.ResponseType = OpenIdConnectResponseType.Code;
options.Events.OnTokenValidated = OnTokenValidated;
});
// site ChallengeScheme options
builder.AddSiteOptions<AuthenticationOptions>((options, alias) =>
{
if (alias.SiteSettings.ContainsKey("OpenIdConnectOptions:Authority") && !string.IsNullOrEmpty(alias.SiteSettings["OpenIdConnectOptions:Authority"]))
{
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
}
});
return builder;
}
private static async Task OnTokenValidated(TokenValidatedContext context)
{
var email = context.Principal.Identity.Name;
if (email != null)
{
var _identityUserManager = context.HttpContext.RequestServices.GetRequiredService<UserManager<IdentityUser>>();
var _users = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
var _userRoles = context.HttpContext.RequestServices.GetRequiredService<IUserRoleRepository>();
User user = null;
var identityuser = await _identityUserManager.FindByEmailAsync(email);
if (identityuser == null)
{
identityuser = new IdentityUser();
identityuser.UserName = email;
identityuser.Email = email;
identityuser.EmailConfirmed = true;
var result = await _identityUserManager.CreateAsync(identityuser, Guid.NewGuid().ToString("N") + "-Xx!");
if (result.Succeeded)
{
user = new User();
user.SiteId = context.HttpContext.GetAlias().SiteId;
user.Username = email;
user.DisplayName = email;
user.Email = email;
user.LastLoginOn = null;
user.LastIPAddress = "";
user = _users.AddUser(user);
// add folder for user
var _folders = context.HttpContext.RequestServices.GetRequiredService<IFolderRepository>();
Folder folder = _folders.GetFolder(user.SiteId, Utilities.PathCombine("Users", Path.DirectorySeparatorChar.ToString()));
if (folder != null)
{
_folders.AddFolder(new Folder
{
SiteId = folder.SiteId,
ParentId = folder.FolderId,
Name = "My Folder",
Type = FolderTypes.Private,
Path = Utilities.PathCombine(folder.Path, user.UserId.ToString(), Path.DirectorySeparatorChar.ToString()),
Order = 1,
ImageSizes = "",
Capacity = Constants.UserFolderCapacity,
IsSystem = true,
Permissions = new List<Permission>
{
new Permission(PermissionNames.Browse, user.UserId, true),
new Permission(PermissionNames.View, RoleNames.Everyone, true),
new Permission(PermissionNames.Edit, user.UserId, true)
}.EncodePermissions()
});
}
// add auto assigned roles to user for site
var _roles = context.HttpContext.RequestServices.GetRequiredService<IRoleRepository>();
List<Role> roles = _roles.GetRoles(user.SiteId).Where(item => item.IsAutoAssigned).ToList();
foreach (Role role in roles)
{
UserRole userrole = new UserRole();
userrole.UserId = user.UserId;
userrole.RoleId = role.RoleId;
userrole.EffectiveDate = null;
userrole.ExpiryDate = null;
_userRoles.AddUserRole(userrole);
}
}
}
else
{
email = identityuser.UserName;
}
// add claims to principal
user = _users.GetUser(email);
if (user != null)
{
List<UserRole> userroles = _userRoles.GetUserRoles(user.UserId, context.HttpContext.GetAlias().SiteId).ToList();
var identity = UserSecurity.CreateClaimsIdentity(context.HttpContext.GetAlias(), user, userroles);
var principalIdentity = (ClaimsIdentity)context.Principal.Identity;
foreach (var claim in identity.Claims)
{
if (!principalIdentity.Claims.Contains(claim))
{
principalIdentity.AddClaim(claim);
}
}
}
}
else
{
var _logger = context.HttpContext.RequestServices.GetRequiredService<ILogManager>();
_logger.Log(LogLevel.Information, "OqtaneSiteAuthenticationBuilderExtensions", Enums.LogFunction.Security, "OpenId Connect Server Did Not Return An Email For User");
}
}
public static bool DecorateService<TService, TImpl>(this IServiceCollection services, params object[] parameters)
{
var existingService = services.SingleOrDefault(s => s.ServiceType == typeof(TService));
if (existingService == null)
return false;
var newService = new ServiceDescriptor(existingService.ServiceType,
sp =>
{
TService inner = (TService)ActivatorUtilities.CreateInstance(sp, existingService.ImplementationType!);
var parameters2 = new object[parameters.Length + 1];
Array.Copy(parameters, 0, parameters2, 1, parameters.Length);
parameters2[0] = inner;
return ActivatorUtilities.CreateInstance<TImpl>(sp, parameters2)!;
},
existingService.Lifetime);
if (existingService.ImplementationInstance != null)
{
newService = new ServiceDescriptor(existingService.ServiceType,
sp =>
{
TService inner = (TService)existingService.ImplementationInstance;
return ActivatorUtilities.CreateInstance<TImpl>(sp, inner, parameters)!;
},
existingService.Lifetime);
}
else if (existingService.ImplementationFactory != null)
{
newService = new ServiceDescriptor(existingService.ServiceType,
sp =>
{
TService inner = (TService)existingService.ImplementationFactory(sp);
return ActivatorUtilities.CreateInstance<TImpl>(sp, inner, parameters)!;
},
existingService.Lifetime);
}
services.Remove(existingService);
services.Add(newService);
return true;
}
}
}

View File

@ -0,0 +1,57 @@
using Microsoft.Extensions.DependencyInjection;
using Oqtane.Models;
using Microsoft.AspNetCore.Identity;
using System;
namespace Oqtane.Extensions
{
public static class OqtaneSiteIdentityBuilderExtensions
{
public static OqtaneSiteOptionsBuilder<TAlias> WithSiteIdentity<TAlias>(
this OqtaneSiteOptionsBuilder<TAlias> builder)
where TAlias : class, IAlias, new()
{
// site identity options
builder.AddSiteOptions<IdentityOptions>((options, alias) =>
{
// password options
if (alias.SiteSettings.ContainsKey("IdentityOptions:Password:RequiredLength"))
{
options.Password.RequiredLength = int.Parse(alias.SiteSettings["IdentityOptions:Password:RequiredLength"]);
}
if (alias.SiteSettings.ContainsKey("IdentityOptions:Password:RequiredUniqueChars"))
{
options.Password.RequiredUniqueChars = int.Parse(alias.SiteSettings["IdentityOptions:Password:RequiredUniqueChars"]);
}
if (alias.SiteSettings.ContainsKey("IdentityOptions:Password:RequireDigit"))
{
options.Password.RequireDigit = bool.Parse(alias.SiteSettings["IdentityOptions:Password:RequireDigit"]);
}
if (alias.SiteSettings.ContainsKey("IdentityOptions:Password:RequireUppercase"))
{
options.Password.RequireUppercase = bool.Parse(alias.SiteSettings["IdentityOptions:Password:RequireUppercase"]);
}
if (alias.SiteSettings.ContainsKey("IdentityOptions:Password:RequireLowercase"))
{
options.Password.RequireLowercase = bool.Parse(alias.SiteSettings["IdentityOptions:Password:RequireLowercase"]);
}
if (alias.SiteSettings.ContainsKey("IdentityOptions:Password:RequireNonAlphanumeric"))
{
options.Password.RequireNonAlphanumeric = bool.Parse(alias.SiteSettings["IdentityOptions:Password:RequireNonAlphanumeric"]);
}
// lockout options
if (alias.SiteSettings.ContainsKey("IdentityOptions:Password:MaxFailedAccessAttempts"))
{
options.Lockout.MaxFailedAccessAttempts = int.Parse(alias.SiteSettings["IdentityOptions:Password:MaxFailedAccessAttempts"]);
}
if (alias.SiteSettings.ContainsKey("IdentityOptions:Password:DefaultLockoutTimeSpan"))
{
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.Parse(alias.SiteSettings["IdentityOptions:Password:DefaultLockoutTimeSpan"]);
}
});
return builder;
}
}
}

View File

@ -0,0 +1,39 @@
using System;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Oqtane.Infrastructure;
using Oqtane.Models;
namespace Microsoft.Extensions.DependencyInjection
{
public partial class OqtaneSiteOptionsBuilder<TSiteOptions> where TSiteOptions : class, IAlias, new()
{
public IServiceCollection Services { get; set; }
public OqtaneSiteOptionsBuilder(IServiceCollection services)
{
Services = services;
}
public OqtaneSiteOptionsBuilder<TSiteOptions> AddSiteOptions<TOptions>(
Action<TOptions, TSiteOptions> siteOptions) where TOptions : class, new()
{
Services.TryAddSingleton<IOptionsMonitorCache<TOptions>, SiteOptionsCache<TOptions, TSiteOptions>>();
Services.AddSingleton<ISiteOptions<TOptions, TSiteOptions>, SiteOptions<TOptions, TSiteOptions>>
(sp => new SiteOptions<TOptions, TSiteOptions>(siteOptions));
Services.TryAddTransient<IOptionsFactory<TOptions>, SiteOptionsFactory<TOptions, TSiteOptions>>();
Services.TryAddScoped<IOptionsSnapshot<TOptions>>(sp => BuildOptionsManager<TOptions>(sp));
Services.TryAddSingleton<IOptions<TOptions>>(sp => BuildOptionsManager<TOptions>(sp));
return this;
}
private static SiteOptionsManager<TOptions> BuildOptionsManager<TOptions>(IServiceProvider sp)
where TOptions : class, new()
{
var cache = ActivatorUtilities.CreateInstance(sp, typeof(SiteOptionsCache<TOptions, TSiteOptions>));
return (SiteOptionsManager<TOptions>)ActivatorUtilities.CreateInstance(sp, typeof(SiteOptionsManager<TOptions>), new[] { cache });
}
}
}

View File

@ -1,7 +1,5 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.StaticFiles;
using Oqtane.Models;
namespace Oqtane.Extensions
{

View File

@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Http;
using Oqtane.Extensions;
using Oqtane.Models;
namespace Oqtane.Infrastructure
{
public class AliasAccessor : IAliasAccessor
{
private readonly IHttpContextAccessor _httpContextAccessor;
public AliasAccessor(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public Alias Alias => _httpContextAccessor.HttpContext.GetAlias();
}
}

View File

@ -0,0 +1,9 @@
using Oqtane.Models;
namespace Oqtane.Infrastructure
{
public interface IAliasAccessor
{
Alias Alias { get; }
}
}

View File

@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace Oqtane.Infrastructure
{
internal class SiteAuthenticationSchemeProvider : IAuthenticationSchemeProvider
{
public SiteAuthenticationSchemeProvider(IOptions<AuthenticationOptions> options)
: this(options, new Dictionary<string, AuthenticationScheme>(StringComparer.Ordinal))
{
}
public SiteAuthenticationSchemeProvider(IOptions<AuthenticationOptions> options, IDictionary<string, AuthenticationScheme> schemes)
{
_optionsProvider = options;
_schemes = schemes ?? throw new ArgumentNullException(nameof(schemes));
_requestHandlers = new List<AuthenticationScheme>();
foreach (var builder in _optionsProvider.Value.Schemes)
{
var scheme = builder.Build();
AddScheme(scheme);
}
}
private readonly IOptions<AuthenticationOptions> _optionsProvider;
private readonly object _lock = new object();
private readonly IDictionary<string, AuthenticationScheme> _schemes;
private readonly List<AuthenticationScheme> _requestHandlers;
private Task<AuthenticationScheme> GetDefaultSchemeAsync()
=> _optionsProvider.Value.DefaultScheme != null
? GetSchemeAsync(_optionsProvider.Value.DefaultScheme)
: Task.FromResult<AuthenticationScheme>(null);
public virtual Task<AuthenticationScheme> GetDefaultAuthenticateSchemeAsync()
=> _optionsProvider.Value.DefaultAuthenticateScheme != null
? GetSchemeAsync(_optionsProvider.Value.DefaultAuthenticateScheme)
: GetDefaultSchemeAsync();
public virtual Task<AuthenticationScheme> GetDefaultChallengeSchemeAsync()
=> _optionsProvider.Value.DefaultChallengeScheme != null
? GetSchemeAsync(_optionsProvider.Value.DefaultChallengeScheme)
: GetDefaultSchemeAsync();
public virtual Task<AuthenticationScheme> GetDefaultForbidSchemeAsync()
=> _optionsProvider.Value.DefaultForbidScheme != null
? GetSchemeAsync(_optionsProvider.Value.DefaultForbidScheme)
: GetDefaultChallengeSchemeAsync();
public virtual Task<AuthenticationScheme> GetDefaultSignInSchemeAsync()
=> _optionsProvider.Value.DefaultSignInScheme != null
? GetSchemeAsync(_optionsProvider.Value.DefaultSignInScheme)
: GetDefaultSchemeAsync();
public virtual Task<AuthenticationScheme> GetDefaultSignOutSchemeAsync()
=> _optionsProvider.Value.DefaultSignOutScheme != null
? GetSchemeAsync(_optionsProvider.Value.DefaultSignOutScheme)
: GetDefaultSignInSchemeAsync();
public virtual Task<AuthenticationScheme> GetSchemeAsync(string name)
=> Task.FromResult(_schemes.ContainsKey(name) ? _schemes[name] : null);
public virtual Task<IEnumerable<AuthenticationScheme>> GetRequestHandlerSchemesAsync()
=> Task.FromResult<IEnumerable<AuthenticationScheme>>(_requestHandlers);
public virtual void AddScheme(AuthenticationScheme scheme)
{
if (_schemes.ContainsKey(scheme.Name))
{
throw new InvalidOperationException("Scheme already exists: " + scheme.Name);
}
lock (_lock)
{
if (_schemes.ContainsKey(scheme.Name))
{
throw new InvalidOperationException("Scheme already exists: " + scheme.Name);
}
if (typeof(IAuthenticationRequestHandler).IsAssignableFrom(scheme.HandlerType))
{
_requestHandlers.Add(scheme);
}
_schemes[scheme.Name] = scheme;
}
}
public virtual void RemoveScheme(string name)
{
if (!_schemes.ContainsKey(name))
{
return;
}
lock (_lock)
{
if (_schemes.ContainsKey(name))
{
var scheme = _schemes[name];
_requestHandlers.Remove(scheme);
_schemes.Remove(name);
}
}
}
public virtual Task<IEnumerable<AuthenticationScheme>> GetAllSchemesAsync()
=> Task.FromResult<IEnumerable<AuthenticationScheme>>(_schemes.Values);
}
}

View File

@ -0,0 +1,62 @@
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Oqtane.Extensions;
using Oqtane.Models;
using Oqtane.Shared;
namespace Oqtane.Infrastructure
{
internal class SiteAuthenticationService<TAlias> : IAuthenticationService
where TAlias : class, IAlias, new()
{
private readonly IAuthenticationService _inner;
public SiteAuthenticationService(IAuthenticationService inner)
{
_inner = inner ?? throw new System.ArgumentNullException(nameof(inner));
}
private static void AddTenantIdentifierToProperties(HttpContext context, ref AuthenticationProperties properties)
{
// add site identifier to the authentication properties so on the callback we can use it to set context
var alias = context.GetAlias();
if (alias != null)
{
properties ??= new AuthenticationProperties();
if (!properties.Items.Keys.Contains(Constants.SiteToken))
{
properties.Items.Add(Constants.SiteToken, alias.SiteKey);
}
}
}
public Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string scheme)
=> _inner.AuthenticateAsync(context, scheme);
public async Task ChallengeAsync(HttpContext context, string scheme, AuthenticationProperties properties)
{
AddTenantIdentifierToProperties(context, ref properties);
await _inner.ChallengeAsync(context, scheme, properties);
}
public async Task ForbidAsync(HttpContext context, string scheme, AuthenticationProperties properties)
{
AddTenantIdentifierToProperties(context, ref properties);
await _inner.ForbidAsync(context, scheme, properties);
}
public async Task SignInAsync(HttpContext context, string scheme, ClaimsPrincipal principal, AuthenticationProperties properties)
{
AddTenantIdentifierToProperties(context, ref properties);
await _inner.SignInAsync(context, scheme, principal, properties);
}
public async Task SignOutAsync(HttpContext context, string scheme, AuthenticationProperties properties)
{
AddTenantIdentifierToProperties(context, ref properties);
await _inner.SignOutAsync(context, scheme, properties);
}
}
}

View File

@ -1,5 +1,8 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Oqtane.Repository;
using Oqtane.Shared;
namespace Oqtane.Infrastructure
{
@ -18,19 +21,30 @@ namespace Oqtane.Infrastructure
var config = context.RequestServices.GetService(typeof(IConfigManager)) as IConfigManager;
if (config.IsInstalled())
{
// get alias
// get alias (note that this also sets SiteState.Alias)
var tenantManager = context.RequestServices.GetService(typeof(ITenantManager)) as ITenantManager;
var alias = tenantManager.GetAlias();
// rewrite path by removing alias path prefix from api and pages requests
if (alias != null && !string.IsNullOrEmpty(alias.Path))
if (alias != null)
{
string path = context.Request.Path.ToString();
if (path.StartsWith("/" + alias.Path) && (path.Contains("/api/") || path.Contains("/pages/")))
// get site settings and store alias in HttpContext
var settingRepository = context.RequestServices.GetService(typeof(ISettingRepository)) as ISettingRepository;
alias.SiteSettings = settingRepository.GetSettings(EntityNames.Site)
.ToDictionary(setting => setting.SettingName, setting => setting.SettingValue);
context.Items.Add(Constants.HttpContextAliasKey, alias);
// rewrite path by removing alias path prefix from api and pages requests (for consistent routing)
if (!string.IsNullOrEmpty(alias.Path))
{
context.Request.Path = path.Replace("/" + alias.Path, "");
string path = context.Request.Path.ToString();
if (path.StartsWith("/" + alias.Path) && (path.Contains("/api/") || path.Contains("/pages/")))
{
context.Request.Path = path.Replace("/" + alias.Path, "");
}
}
}
}
// continue processing

View File

@ -0,0 +1,12 @@
using Oqtane.Models;
namespace Oqtane.Infrastructure
{
public interface ISiteOptions<TOptions, TAlias>
where TOptions : class, new()
where TAlias : class, IAlias, new()
{
void Configure(TOptions options, TAlias siteOptions);
}
}

View File

@ -0,0 +1,22 @@
using System;
using Oqtane.Models;
namespace Oqtane.Infrastructure
{
public class SiteOptions<TOptions, TAlias> : ISiteOptions<TOptions, TAlias>
where TOptions : class, new()
where TAlias : class, IAlias, new()
{
private readonly Action<TOptions, TAlias> configureOptions;
public SiteOptions(Action<TOptions, TAlias> configureOptions)
{
this.configureOptions = configureOptions;
}
public void Configure(TOptions options, TAlias siteOptions)
{
configureOptions(options, siteOptions);
}
}
}

View File

@ -0,0 +1,70 @@
using System;
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using Oqtane.Models;
namespace Oqtane.Infrastructure
{
public class SiteOptionsCache<TOptions, TAlias> : IOptionsMonitorCache<TOptions>
where TOptions : class
where TAlias : class, IAlias, new()
{
private readonly IAliasAccessor _aliasAccessor;
private readonly ConcurrentDictionary<string, IOptionsMonitorCache<TOptions>> map = new ConcurrentDictionary<string, IOptionsMonitorCache<TOptions>>();
public SiteOptionsCache(IAliasAccessor aliasAccessor)
{
_aliasAccessor = aliasAccessor;
}
public void Clear()
{
var cache = map.GetOrAdd(GetKey(), new OptionsCache<TOptions>());
cache.Clear();
}
public void Clear(Alias alias)
{
var cache = map.GetOrAdd(alias.SiteKey, new OptionsCache<TOptions>());
cache.Clear();
}
public void ClearAll()
{
foreach (var cache in map.Values)
{
cache.Clear();
}
}
public TOptions GetOrAdd(string name, Func<TOptions> createOptions)
{
name = name ?? Options.DefaultName;
var cache = map.GetOrAdd(GetKey(), new OptionsCache<TOptions>());
return cache.GetOrAdd(name, createOptions);
}
public bool TryAdd(string name, TOptions options)
{
name = name ?? Options.DefaultName;
var cache = map.GetOrAdd(GetKey(), new OptionsCache<TOptions>());
return cache.TryAdd(name, options);
}
public bool TryRemove(string name)
{
name = name ?? Options.DefaultName;
var cache = map.GetOrAdd(GetKey(), new OptionsCache<TOptions>());
return cache.TryRemove(name);
}
private string GetKey()
{
return _aliasAccessor?.Alias?.SiteKey ?? "";
}
}
}

View File

@ -0,0 +1,77 @@
using System.Collections.Generic;
using Microsoft.Extensions.Options;
using Oqtane.Models;
namespace Oqtane.Infrastructure
{
public class SiteOptionsFactory<TOptions, TAlias> : IOptionsFactory<TOptions>
where TOptions : class, new()
where TAlias : class, IAlias, new()
{
private readonly IConfigureOptions<TOptions>[] _configureOptions;
private readonly IPostConfigureOptions<TOptions>[] _postConfigureOptions;
private readonly IValidateOptions<TOptions>[] _validations;
private readonly ISiteOptions<TOptions, TAlias>[] _siteOptions;
private readonly IAliasAccessor _aliasAccessor;
public SiteOptionsFactory(IEnumerable<IConfigureOptions<TOptions>> configureOptions, IEnumerable<IPostConfigureOptions<TOptions>> postConfigureOptions, IEnumerable<IValidateOptions<TOptions>> validations, IEnumerable<ISiteOptions<TOptions, TAlias>> siteOptions, IAliasAccessor aliasAccessor)
{
_configureOptions = configureOptions as IConfigureOptions<TOptions>[] ?? new List<IConfigureOptions<TOptions>>(configureOptions).ToArray();
_postConfigureOptions = postConfigureOptions as IPostConfigureOptions<TOptions>[] ?? new List<IPostConfigureOptions<TOptions>>(postConfigureOptions).ToArray();
_validations = validations as IValidateOptions<TOptions>[] ?? new List<IValidateOptions<TOptions>>(validations).ToArray();
_siteOptions = siteOptions as ISiteOptions<TOptions, TAlias>[] ?? new List<ISiteOptions<TOptions, TAlias>>(siteOptions).ToArray();
_aliasAccessor = aliasAccessor;
}
public TOptions Create(string name)
{
// default options
var options = new TOptions();
foreach (var setup in _configureOptions)
{
if (setup is IConfigureNamedOptions<TOptions> namedSetup)
{
namedSetup.Configure(name, options);
}
else if (name == Options.DefaultName)
{
setup.Configure(options);
}
}
// override with site specific options
if (_aliasAccessor?.Alias != null)
{
foreach (var siteOption in _siteOptions)
{
siteOption.Configure(options, _aliasAccessor.Alias as TAlias);
}
}
// post configuration
foreach (var post in _postConfigureOptions)
{
post.PostConfigure(name, options);
}
//if (_validations.Length > 0)
//{
// var failures = new List<string>();
// foreach (IValidateOptions<TOptions> validate in _validations)
// {
// ValidateOptionsResult result = validate.Validate(name, options);
// if (result != null && result.Failed)
// {
// failures.AddRange(result.Failures);
// }
// }
// if (failures.Count > 0)
// {
// throw new OptionsValidationException(name, typeof(TOptions), failures);
// }
//}
return options;
}
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.Extensions.Options;
namespace Oqtane.Infrastructure
{
public class SiteOptionsManager<TOptions> : IOptions<TOptions>, IOptionsSnapshot<TOptions> where TOptions : class, new()
{
private readonly IOptionsFactory<TOptions> _factory;
private readonly IOptionsMonitorCache<TOptions> _cache; // private cache
public SiteOptionsManager(IOptionsFactory<TOptions> factory, IOptionsMonitorCache<TOptions> cache)
{
_factory = factory;
_cache = cache;
}
public TOptions Value
{
get
{
return Get(Options.DefaultName);
}
}
public virtual TOptions Get(string name)
{
name = name ?? Options.DefaultName;
return _cache.GetOrAdd(name, () => _factory.Create(name));
}
public void Reset()
{
_cache.Clear();
}
}
}

View File

@ -26,7 +26,7 @@ namespace Oqtane.Infrastructure
{
Alias alias = null;
if (_siteState != null && _siteState.Alias != null)
if (_siteState != null && _siteState.Alias != null && _siteState.Alias.AliasId != -1)
{
alias = _siteState.Alias;
}

View File

@ -32,6 +32,7 @@
<EmbeddedResource Include="Scripts\MigrateTenant.sql" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.0" />

View File

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

View File

@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Oqtane.Pages
{
public class OIDCModel : PageModel
{
public IActionResult OnGetAsync(string returnurl)
{
return new ChallengeResult(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = !string.IsNullOrEmpty(returnurl) ? returnurl : "/" });
}
}
}

View File

@ -37,17 +37,20 @@ namespace Oqtane.Security
var userRepository = context.HttpContext.RequestServices.GetService(typeof(IUserRepository)) as IUserRepository;
var userRoleRepository = context.HttpContext.RequestServices.GetService(typeof(IUserRoleRepository)) as IUserRoleRepository;
User user = userRepository.GetUser(context.Principal.Identity.Name);
if (user != null)
if (context.Principal.Identity.Name != null)
{
List<UserRole> userroles = userRoleRepository.GetUserRoles(user.UserId, alias.SiteId).ToList();
var identity = UserSecurity.CreateClaimsIdentity(alias, user, userroles);
context.ReplacePrincipal(new ClaimsPrincipal(identity));
context.ShouldRenew = true;
}
else
{
context.RejectPrincipal();
User user = userRepository.GetUser(context.Principal.Identity.Name);
if (user != null)
{
List<UserRole> userroles = userRoleRepository.GetUserRoles(user.UserId, alias.SiteId).ToList();
var identity = UserSecurity.CreateClaimsIdentity(alias, user, userroles);
context.ReplacePrincipal(new ClaimsPrincipal(identity));
context.ShouldRenew = true;
}
else
{
context.RejectPrincipal();
}
}
}
}

View File

@ -16,6 +16,9 @@ using Oqtane.Repository;
using Oqtane.Security;
using Oqtane.Shared;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using System.Threading.Tasks;
namespace Oqtane
{
@ -72,37 +75,12 @@ namespace Oqtane
// setup HttpClient for server side in a client side compatible fashion ( with auth cookie )
services.TryAddHttpClientWithAuthenticationCookie();
// register custom authorization policies
services.AddOqtaneAuthorizationPolicies();
// register scoped core services
services.AddScoped<IAuthorizationHandler, PermissionHandler>()
.AddOqtaneScopedServices();
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddIdentityCore<IdentityUser>(options => { })
.AddEntityFrameworkStores<TenantDBContext>()
.AddSignInManager()
.AddDefaultTokenProviders()
.AddClaimsPrincipalFactory<ClaimsPrincipalFactory<IdentityUser>>(); // role claims
services.ConfigureOqtaneIdentityOptions(Configuration);
services.AddAuthentication(Constants.AuthenticationScheme)
.AddCookie(Constants.AuthenticationScheme);
services.ConfigureOqtaneCookieOptions();
services.AddAntiforgery(options =>
{
options.HeaderName = Constants.AntiForgeryTokenHeaderName;
options.Cookie.HttpOnly = false;
options.Cookie.Name = Constants.AntiForgeryTokenCookieName;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
});
// register singleton scoped core services
services.AddSingleton(Configuration)
.AddOqtaneSingletonServices();
@ -117,10 +95,43 @@ namespace Oqtane
services.AddOqtane(_supportedCultures);
services.AddOqtaneDbContext();
services.AddAntiforgery(options =>
{
options.HeaderName = Constants.AntiForgeryTokenHeaderName;
options.Cookie.Name = Constants.AntiForgeryTokenCookieName;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
//options.Cookie.HttpOnly = false;
});
services.AddIdentityCore<IdentityUser>(options => { })
.AddEntityFrameworkStores<TenantDBContext>()
.AddSignInManager()
.AddDefaultTokenProviders()
.AddClaimsPrincipalFactory<ClaimsPrincipalFactory<IdentityUser>>(); // role claims
services.ConfigureOqtaneIdentityOptions(Configuration);
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = Constants.AuthenticationScheme;
options.DefaultChallengeScheme = Constants.AuthenticationScheme;
})
.AddCookie(Constants.AuthenticationScheme)
.AddOpenIdConnect();
services.ConfigureOqtaneCookieOptions();
services.AddOqtaneSiteOptions<Alias>()
.WithSiteIdentity()
.WithSiteAuthentication();
services.AddOqtaneAuthorizationPolicies();
services.AddMvc()
.AddNewtonsoftJson()
.AddOqtaneApplicationParts() // register any Controllers from custom modules
.ConfigureOqtaneMvc(); // any additional configuration from IStart classes.
.ConfigureOqtaneMvc(); // any additional configuration from IStartup classes
services.AddSwaggerGen(options =>
{

View File

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