From 99b80388524aa0eca6c392189faea70749d3f412 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Wed, 25 Jan 2023 08:37:59 -0600 Subject: [PATCH] add oidc support (#1133) --- CHANGELOG.md | 8 ++ ErsatzTV/App.razor | 24 ++--- ErsatzTV/Controllers/AccountController.cs | 13 +++ ErsatzTV/ErsatzTV.csproj | 2 + ErsatzTV/OidcHelper.cs | 20 +++++ ErsatzTV/Shared/MainLayout.razor | 103 ++++++++++++---------- ErsatzTV/Startup.cs | 67 +++++++++++++- ErsatzTV/wwwroot/css/site.css | 2 +- 8 files changed, 177 insertions(+), 62 deletions(-) create mode 100644 ErsatzTV/Controllers/AccountController.cs create mode 100644 ErsatzTV/OidcHelper.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index cdcd0d07f..14e99f65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added - Attempt to release memory periodically +- Add SSO support via OIDC + - This only protects the management UI; all streaming endpoints will continue to allow anonymous access + - This can be configured with the following env vars (note the double underscore separator `__`) + - `OIDC__AUTHORITY` + - `OIDC__CLIENTID` + - `OIDC__CLIENTSECRET` ### Fixed - Fix schedule editor crashing due to bad music video artist data @@ -18,6 +24,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - This ensures errors will display even when hardware acceleration is misconfigured - Call scanner process only when scanning is required based on library refresh interval - Use lower process priority for scanner process with unforced (automatic) library scans +- Disable V2 UI by default + - V2 UI can be re-enabled by setting the env var `ETV_UI_V2` to any value ## [0.7.2-beta] - 2023-01-05 ### Fixed diff --git a/ErsatzTV/App.razor b/ErsatzTV/App.razor index 03dcec2f6..10f91c819 100644 --- a/ErsatzTV/App.razor +++ b/ErsatzTV/App.razor @@ -1,11 +1,13 @@ - - - - - - Not found - -

Sorry, there's nothing at this address.

-
-
-
\ No newline at end of file + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
+
\ No newline at end of file diff --git a/ErsatzTV/Controllers/AccountController.cs b/ErsatzTV/Controllers/AccountController.cs new file mode 100644 index 000000000..e1752cdd7 --- /dev/null +++ b/ErsatzTV/Controllers/AccountController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers; + +[ApiController] +public class AccountController : ControllerBase +{ + [HttpPost("account/logout")] + public IActionResult Logout() + { + return new SignOutResult(new[] { "oidc", "cookie" }); + } +} diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index b79479755..de6c018af 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -12,6 +12,7 @@ true Debug;Release;Debug No Sync AnyCPU + bf31217d-f4ec-4520-8cc3-138059044ede @@ -60,6 +61,7 @@ + diff --git a/ErsatzTV/OidcHelper.cs b/ErsatzTV/OidcHelper.cs new file mode 100644 index 000000000..aa533c169 --- /dev/null +++ b/ErsatzTV/OidcHelper.cs @@ -0,0 +1,20 @@ +namespace ErsatzTV; + +public static class OidcHelper +{ + public static void Init(IConfiguration configuration) + { + Authority = configuration["OIDC:Authority"]; + ClientId = configuration["OIDC:ClientId"]; + ClientSecret = configuration["OIDC:ClientSecret"]; + + IsEnabled = !string.IsNullOrWhiteSpace(Authority) && + !string.IsNullOrWhiteSpace(ClientId) && + !string.IsNullOrWhiteSpace(ClientSecret); + } + + public static string Authority { get; private set; } + public static string ClientId { get; private set; } + public static string ClientSecret { get; private set; } + public static bool IsEnabled { get; private set; } +} diff --git a/ErsatzTV/Shared/MainLayout.razor b/ErsatzTV/Shared/MainLayout.razor index 66f5a16c3..0986edbe7 100644 --- a/ErsatzTV/Shared/MainLayout.razor +++ b/ErsatzTV/Shared/MainLayout.razor @@ -3,8 +3,8 @@ @using ErsatzTV.Extensions @using ErsatzTV.Application.Search @implements IDisposable -@inject NavigationManager _navigationManager -@inject IMediator _mediator +@inject NavigationManager NavigationManager +@inject IMediator Mediator @@ -17,49 +17,51 @@ ErsatzTV - - - - - @if (!string.IsNullOrWhiteSpace(_query) && _query.Length >= 3) - { - var matches = _searchTargets.Where(s => s.Name.Contains(_query, StringComparison.CurrentCultureIgnoreCase)).ToList(); - if (matches.Any()) +
+ + + + + @if (!string.IsNullOrWhiteSpace(_query) && _query.Length >= 3) { - - @foreach (SearchTargetViewModel searchTarget in matches) - { - - @searchTarget.Name - - @(searchTarget.Kind switch - { - SearchTargetKind.Channel => "Channel", - SearchTargetKind.FFmpegProfile => "FFmpeg Profile", - SearchTargetKind.ChannelWatermark => "Channel Watermark", - SearchTargetKind.Collection => "Collection", - SearchTargetKind.MultiCollection => "Multi Collection", - SearchTargetKind.SmartCollection => "Smart Collection", - SearchTargetKind.Schedule => "Schedule", - SearchTargetKind.ScheduleItems => "Schedule Items", - _ => string.Empty - }) - - - } - + var matches = _searchTargets.Where(s => s.Name.Contains(_query, StringComparison.CurrentCultureIgnoreCase)).ToList(); + if (matches.Any()) + { + + @foreach (SearchTargetViewModel searchTarget in matches) + { + + @searchTarget.Name + + @(searchTarget.Kind switch + { + SearchTargetKind.Channel => "Channel", + SearchTargetKind.FFmpegProfile => "FFmpeg Profile", + SearchTargetKind.ChannelWatermark => "Channel Watermark", + SearchTargetKind.Collection => "Collection", + SearchTargetKind.MultiCollection => "Multi Collection", + SearchTargetKind.SmartCollection => "Smart Collection", + SearchTargetKind.Schedule => "Schedule", + SearchTargetKind.ScheduleItems => "Schedule Items", + _ => string.Empty + }) + + + } + + } } - } - - + + +
M3U XMLTV @@ -73,6 +75,13 @@ + +
+ + + +
+
@@ -176,17 +185,17 @@ protected override async Task OnParametersSetAsync() { await base.OnParametersSetAsync(); - _query = _navigationManager.Uri.GetSearchQuery(); + _query = NavigationManager.Uri.GetSearchQuery(); if (_searchTargets is null) { - _searchTargets = await _mediator.Send(new QuerySearchTargets(), _cts.Token); + _searchTargets = await Mediator.Send(new QuerySearchTargets(), _cts.Token); } } private void PerformSearch() { - _navigationManager.NavigateTo(_query.GetRelativeSearchQuery(), true); + NavigationManager.NavigateTo(_query.GetRelativeSearchQuery(), true); StateHasChanged(); } @@ -206,7 +215,7 @@ private void NavigateTo(SearchTargetViewModel searchTarget) => // need to force smart collections to navigate since the query string is all that differs - _navigationManager.NavigateTo(UrlFor(searchTarget), searchTarget.Kind is SearchTargetKind.SmartCollection); + NavigationManager.NavigateTo(UrlFor(searchTarget), searchTarget.Kind is SearchTargetKind.SmartCollection); private string UrlFor(SearchTargetViewModel searchTarget) => searchTarget.Kind switch diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 021c00e59..809eadf9b 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -60,6 +60,7 @@ using FluentValidation.AspNetCore; using Ganss.Xss; using MediatR; using MediatR.Courier.DependencyInjection; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.StaticFiles; using Microsoft.EntityFrameworkCore; @@ -118,6 +119,43 @@ public class Startup #endif }); + OidcHelper.Init(Configuration); + + if (OidcHelper.IsEnabled) + { + services.AddAuthentication( + options => + { + options.DefaultScheme = "cookie"; + options.DefaultChallengeScheme = "oidc"; + }) + .AddCookie("cookie", options => + { + options.CookieManager = new ChunkingCookieManager(); + + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.None; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + }) + .AddOpenIdConnect( + "oidc", + options => + { + options.Authority = OidcHelper.Authority; + options.ClientId = OidcHelper.ClientId; + options.ClientSecret = OidcHelper.ClientSecret; + + options.ResponseType = "code"; + options.UsePkce = true; + options.ResponseMode = "query"; + + options.SaveTokens = true; + + options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always; + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; + }); + } + services.AddCors( o => o.AddPolicy( "AllowAll", @@ -149,14 +187,23 @@ public class Startup services.AddFluentValidationAutoValidation(); services.AddValidatorsFromAssemblyContaining(); - if (!CurrentEnvironment.IsDevelopment()) + string v2 = Environment.GetEnvironmentVariable("ETV_UI_V2"); + if (!CurrentEnvironment.IsDevelopment() && !string.IsNullOrWhiteSpace(v2)) { services.AddSpaStaticFiles(options => options.RootPath = "wwwroot/v2"); } services.AddMemoryCache(); - services.AddRazorPages(); + services.AddRazorPages( + options => + { + if (OidcHelper.IsEnabled) + { + options.Conventions.AuthorizeFolder("/"); + } + }); + services.AddServerSideBlazor(); services.AddMudServices(); @@ -317,8 +364,15 @@ public class Startup }); app.UseRouting(); + + if (OidcHelper.IsEnabled) + { + app.UseAuthentication(); + app.UseAuthorization(); + } - if (!env.IsDevelopment()) + string v2 = Environment.GetEnvironmentVariable("ETV_UI_V2"); + if (!env.IsDevelopment() && !string.IsNullOrWhiteSpace(v2)) { app.Map( "/v2", @@ -330,6 +384,13 @@ public class Startup } app2.UseRouting(); + + if (OidcHelper.IsEnabled) + { + app.UseAuthentication(); + app.UseAuthorization(); + } + app2.UseEndpoints(e => e.MapFallbackToFile("index.html")); app2.UseFileServer( new FileServerOptions diff --git a/ErsatzTV/wwwroot/css/site.css b/ErsatzTV/wwwroot/css/site.css index 1179ba5de..a6bb712dd 100644 --- a/ErsatzTV/wwwroot/css/site.css +++ b/ErsatzTV/wwwroot/css/site.css @@ -74,7 +74,7 @@ border-radius: 4px; } -.app-bar form { flex-grow: 1; } +.app-bar .search-form { flex-grow: 1; } .fanart-container { position: relative;