More improvements to OIDC support

This commit is contained in:
Shaun Walker
2022-03-19 13:42:19 -04:00
parent 39dfc00693
commit 1a86b80c61
12 changed files with 230 additions and 93 deletions

View File

@ -8,6 +8,9 @@ using Oqtane.Enums;
using Oqtane.Infrastructure;
using Oqtane.Repository;
using System.Net;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization;
namespace Oqtane.Controllers
{
@ -20,14 +23,16 @@ namespace Oqtane.Controllers
private readonly ISyncManager _syncManager;
private readonly ILogManager _logger;
private readonly Alias _alias;
private readonly IOptionsMonitorCache<OpenIdConnectOptions> _optionsMonitorCache;
private readonly string _visitorCookie;
public SettingController(ISettingRepository settings, IPageModuleRepository pageModules, IUserPermissions userPermissions, ITenantManager tenantManager, ISyncManager syncManager, ILogManager logger)
public SettingController(ISettingRepository settings, IPageModuleRepository pageModules, IUserPermissions userPermissions, ITenantManager tenantManager, ISyncManager syncManager, IOptionsMonitorCache<OpenIdConnectOptions> optionsMonitorCache, ILogManager logger)
{
_settings = settings;
_pageModules = pageModules;
_userPermissions = userPermissions;
_syncManager = syncManager;
_optionsMonitorCache = optionsMonitorCache;
_logger = logger;
_alias = tenantManager.GetAlias();
_visitorCookie = "APP_VISITOR_" + _alias.SiteId.ToString();
@ -131,6 +136,15 @@ namespace Oqtane.Controllers
}
}
// DELETE api/<controller>/clear
[HttpDelete("clear/{id}")]
[Authorize(Roles = RoleNames.Admin)]
public void Clear(int id)
{
_optionsMonitorCache.Clear();
_logger.Log(LogLevel.Information, this, LogFunction.Other, "Site Options Cache Cleared");
}
private bool IsAuthorized(string entityName, int entityId, string permissionName)
{
bool authorized = false;

View File

@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace Oqtane.Extensions
{
public static class DictionaryExtensions
{
public static TValue GetValue<TKey, TValue>(this Dictionary<TKey, TValue> dictionary, TKey key, TValue defaultValue, bool nullOrEmptyValueIsValid = false)
{
if (dictionary != null && key != null && dictionary.ContainsKey(key))
{
if (nullOrEmptyValueIsValid || (dictionary[key] != null && !string.IsNullOrEmpty(dictionary[key].ToString())))
{
return dictionary[key];
}
}
return defaultValue;
}
}
}

View File

@ -15,6 +15,8 @@ using Oqtane.Repository;
using System.IO;
using System.Collections.Generic;
using Oqtane.Security;
using System.Net;
using Microsoft.AspNetCore.Http;
namespace Oqtane.Extensions
{
@ -47,38 +49,41 @@ namespace Oqtane.Extensions
// 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.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.CallbackPath = string.IsNullOrEmpty(alias.Path) ? "/signin-oidc" : "/" + alias.Path + "/signin-oidc";
options.ResponseType = OpenIdConnectResponseType.Code; // authorization code flow
options.ResponseMode = OpenIdConnectResponseMode.FormPost; // recommended as most secure
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.Scope.Add("offline_access"); // refresh token
// cookie config is required to avoid Correlation Failed errors
options.NonceCookie.SameSite = SameSiteMode.Unspecified;
options.CorrelationCookie.SameSite = SameSiteMode.Unspecified;
// site options
options.Authority = alias.SiteSettings.GetValue("OpenIdConnectOptions:Authority", options.Authority);
options.ClientId = alias.SiteSettings.GetValue("OpenIdConnectOptions:ClientId", options.ClientId);
options.ClientSecret = alias.SiteSettings.GetValue("OpenIdConnectOptions:ClientSecret", options.ClientSecret);
options.MetadataAddress = alias.SiteSettings.GetValue("OpenIdConnectOptions:MetadataAddress", options.MetadataAddress);
// openid connect events
options.Events.OnTokenValidated = OnTokenValidated;
options.Events.OnRedirectToIdentityProvider = OnRedirectToIdentityProvider;
options.Events.OnRedirectToIdentityProviderForSignOut = OnRedirectToIdentityProviderForSignOut;
options.Events.OnRemoteFailure = OnRemoteFailure;
});
// site ChallengeScheme options
builder.AddSiteOptions<AuthenticationOptions>((options, alias) =>
{
if (alias.SiteSettings.ContainsKey("OpenIdConnectOptions:Authority") && !string.IsNullOrEmpty(alias.SiteSettings["OpenIdConnectOptions:Authority"]))
if (alias.SiteSettings.GetValue("OpenIdConnectOptions:Authority", "") != "")
{
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
}
@ -175,7 +180,7 @@ namespace Oqtane.Extensions
else
{
// provider keys do not match
_logger.Log(LogLevel.Error, nameof(OqtaneSiteAuthenticationBuilderExtensions), Enums.LogFunction.Security, "OpenId Connect Server Provider Key Does Not Match For User {Email}", email);
_logger.Log(LogLevel.Error, nameof(OqtaneSiteAuthenticationBuilderExtensions), Enums.LogFunction.Security, "OpenId Connect Provider Key Does Not Match For User {Email}", email);
}
}
else
@ -208,14 +213,53 @@ namespace Oqtane.Extensions
List<UserRole> userroles = _userRoles.GetUserRoles(user.UserId, context.HttpContext.GetAlias().SiteId).ToList();
var identity = UserSecurity.CreateClaimsIdentity(context.HttpContext.GetAlias(), user, userroles);
principal.AddClaims(identity.Claims);
// add provider
principal.AddClaim(new Claim("Provider", context.HttpContext.GetAlias().SiteSettings["OpenIdConnectOptions:Authority"]));
}
}
else
{
_logger.Log(LogLevel.Information, nameof(OqtaneSiteAuthenticationBuilderExtensions), Enums.LogFunction.Security, "OpenId Connect Server Did Not Return An Email Claim");
_logger.Log(LogLevel.Information, nameof(OqtaneSiteAuthenticationBuilderExtensions), Enums.LogFunction.Security, "OpenId Connect Provider Did Not Return An Email Claim");
}
}
private static Task OnRedirectToIdentityProvider(RedirectContext context)
{
//context.ProtocolMessage.SetParameter("key", "value");
return Task.CompletedTask;
}
private static Task OnRedirectToIdentityProviderForSignOut(RedirectContext context)
{
var logoutUrl = context.HttpContext.GetAlias().SiteSettings.GetValue("OpenIdConnectOptions:LogoutUrl", "");
if (logoutUrl != "")
{
var postLogoutUri = context.Properties.RedirectUri;
if (!string.IsNullOrEmpty(postLogoutUri))
{
if (postLogoutUri.StartsWith("/"))
{
var request = context.Request;
postLogoutUri = request.Scheme + "://" + request.Host + request.PathBase + postLogoutUri;
}
logoutUrl += $"&returnTo={Uri.EscapeDataString(postLogoutUri)}";
}
context.Response.Redirect(logoutUrl);
context.HandleResponse();
}
return Task.CompletedTask;
}
private static Task OnRemoteFailure(RemoteFailureContext context)
{
var _logger = context.HttpContext.RequestServices.GetRequiredService<ILogManager>();
_logger.Log(LogLevel.Error, nameof(OqtaneSiteAuthenticationBuilderExtensions), Enums.LogFunction.Security, "OpenId Connect Remote Failure {Error}", context.Failure.Message);
context.Response.Redirect(context.Properties.RedirectUri);
context.HandleResponse();
return Task.CompletedTask;
}
public static bool DecorateService<TService, TImpl>(this IServiceCollection services, params object[] parameters)
{
var existingService = services.SingleOrDefault(s => s.ServiceType == typeof(TService));

View File

@ -15,40 +15,16 @@ namespace Oqtane.Extensions
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"]);
}
options.Password.RequiredLength = int.Parse(alias.SiteSettings.GetValue("IdentityOptions:Password:RequiredLength", options.Password.RequiredLength.ToString()));
options.Password.RequiredUniqueChars = int.Parse(alias.SiteSettings.GetValue("IdentityOptions:Password:RequiredUniqueChars", options.Password.RequiredUniqueChars.ToString()));
options.Password.RequireDigit = bool.Parse(alias.SiteSettings.GetValue("IdentityOptions:Password:RequireDigit", options.Password.RequireDigit.ToString()));
options.Password.RequireUppercase = bool.Parse(alias.SiteSettings.GetValue("IdentityOptions:Password:RequireUppercase", options.Password.RequireUppercase.ToString()));
options.Password.RequireLowercase = bool.Parse(alias.SiteSettings.GetValue("IdentityOptions:Password:RequireLowercase", options.Password.RequireLowercase.ToString()));
options.Password.RequireNonAlphanumeric = bool.Parse(alias.SiteSettings.GetValue("IdentityOptions:Password:RequireNonAlphanumeric", options.Password.RequireNonAlphanumeric.ToString()));
// 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"]);
}
options.Lockout.MaxFailedAccessAttempts = int.Parse(alias.SiteSettings.GetValue("IdentityOptions:Password:MaxFailedAccessAttempts", options.Lockout.MaxFailedAccessAttempts.ToString()));
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.Parse(alias.SiteSettings.GetValue("IdentityOptions:Password:DefaultLockoutTimeSpan", options.Lockout.DefaultLockoutTimeSpan.ToString()));
});
return builder;

View File

@ -1,6 +1,7 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Oqtane.Repository;
using Oqtane.Shared;
@ -27,10 +28,15 @@ namespace Oqtane.Infrastructure
if (alias != null)
{
// 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);
// get site settings
var cache = context.RequestServices.GetService(typeof(IMemoryCache)) as IMemoryCache;
alias.SiteSettings = cache.GetOrCreate("sitesettings:" + alias.SiteKey, entry =>
{
var settingRepository = context.RequestServices.GetService(typeof(ISettingRepository)) as ISettingRepository;
return settingRepository.GetSettings(EntityNames.Site)
.ToDictionary(setting => setting.SettingName, setting => setting.SettingValue);
});
// save alias in HttpContext
context.Items.Add(Constants.HttpContextAliasKey, alias);
// rewrite path by removing alias path prefix from api and pages requests (for consistent routing)
@ -42,9 +48,7 @@ namespace Oqtane.Infrastructure
context.Request.Path = path.Replace("/" + alias.Path, "");
}
}
}
}
// continue processing

View File

@ -1,32 +1,37 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Oqtane.Extensions;
using Oqtane.Shared;
namespace Oqtane.Pages
{
[AllowAnonymous]
[Authorize]
public class LogoutModel : PageModel
{
public async Task<IActionResult> OnGetAsync(string returnurl)
{
if (HttpContext.User.Identity.IsAuthenticated)
{
await HttpContext.SignOutAsync(Constants.AuthenticationScheme);
}
await HttpContext.SignOutAsync(Constants.AuthenticationScheme);
if (returnurl == null)
{
returnurl = "";
}
if (!returnurl.StartsWith("/"))
{
returnurl = "/" + returnurl;
}
returnurl = (returnurl == null) ? "/" : returnurl;
returnurl = (!returnurl.StartsWith("/")) ? "/" + returnurl : returnurl;
return LocalRedirect(Url.Content("~" + returnurl));
var provider = HttpContext.User.Claims.FirstOrDefault(item => item.Type == "Provider");
var authority = HttpContext.GetAlias().SiteSettings.GetValue("OpenIdConnectOptions:Authority", "");
var logoutUrl = HttpContext.GetAlias().SiteSettings.GetValue("OpenIdConnectOptions:LogoutUrl", "");
if (provider != null && provider.Value == authority && logoutUrl != "")
{
return new SignOutResult(OpenIdConnectDefaults.AuthenticationScheme,
new AuthenticationProperties { RedirectUri = returnurl });
}
else
{
return LocalRedirect(Url.Content("~" + returnurl));
}
}
}
}

View File

@ -9,7 +9,11 @@ namespace Oqtane.Pages
{
public IActionResult OnGetAsync(string returnurl)
{
return new ChallengeResult(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = !string.IsNullOrEmpty(returnurl) ? returnurl : "/" });
returnurl = (returnurl == null) ? "/" : returnurl;
returnurl = (!returnurl.StartsWith("/")) ? "/" + returnurl : returnurl;
return new ChallengeResult(OpenIdConnectDefaults.AuthenticationScheme,
new AuthenticationProperties { RedirectUri = returnurl });
}
}
}

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Oqtane.Models;
using Oqtane.Shared;
@ -10,11 +11,15 @@ namespace Oqtane.Repository
{
private TenantDBContext _tenant;
private MasterDBContext _master;
private readonly SiteState _siteState;
private readonly IMemoryCache _cache;
public SettingRepository(TenantDBContext tenant, MasterDBContext master)
public SettingRepository(TenantDBContext tenant, MasterDBContext master, SiteState siteState, IMemoryCache cache)
{
_tenant = tenant;
_master = master;
_siteState = siteState;
_cache = cache;
}
public IEnumerable<Setting> GetSettings(string entityName)
@ -47,6 +52,7 @@ namespace Oqtane.Repository
_tenant.Setting.Add(setting);
_tenant.SaveChanges();
}
ManageCache(setting.EntityName);
return setting;
}
@ -62,6 +68,7 @@ namespace Oqtane.Repository
_tenant.Entry(setting).State = EntityState.Modified;
_tenant.SaveChanges();
}
ManageCache(setting.EntityName);
return setting;
}
@ -103,6 +110,7 @@ namespace Oqtane.Repository
_tenant.Setting.Remove(setting);
_tenant.SaveChanges();
}
ManageCache(entityName);
}
public void DeleteSettings(string entityName, int entityId)
@ -129,11 +137,20 @@ namespace Oqtane.Repository
}
_tenant.SaveChanges();
}
ManageCache(entityName);
}
private bool IsMaster(string EntityName)
{
return (EntityName == EntityNames.ModuleDefinition || EntityName == EntityNames.Host);
}
private void ManageCache(string EntityName)
{
if (EntityName == EntityNames.Site)
{
_cache.Remove("sitesettings:" + _siteState.Alias.SiteKey);
}
}
}
}