Browse Source

add oidc support (#1133)

pull/1134/head
Jason Dove 4 years ago committed by GitHub
parent
commit
99b8038852
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      CHANGELOG.md
  2. 24
      ErsatzTV/App.razor
  3. 13
      ErsatzTV/Controllers/AccountController.cs
  4. 2
      ErsatzTV/ErsatzTV.csproj
  5. 20
      ErsatzTV/OidcHelper.cs
  6. 103
      ErsatzTV/Shared/MainLayout.razor
  7. 67
      ErsatzTV/Startup.cs
  8. 2
      ErsatzTV/wwwroot/css/site.css

8
CHANGELOG.md

@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- Attempt to release memory periodically - 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 ### Fixed
- Fix schedule editor crashing due to bad music video artist data - 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 - This ensures errors will display even when hardware acceleration is misconfigured
- Call scanner process only when scanning is required based on library refresh interval - 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 - 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 ## [0.7.2-beta] - 2023-01-05
### Fixed ### Fixed

24
ErsatzTV/App.razor

@ -1,11 +1,13 @@
<Router AppAssembly="@typeof(Program).Assembly"> <CascadingAuthenticationState>
<Found Context="routeData"> <Router AppAssembly="@typeof(Program).Assembly">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/> <Found Context="routeData">
</Found> <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<NotFound> </Found>
<PageTitle>Not found</PageTitle> <NotFound>
<LayoutView Layout="@typeof(MainLayout)"> <PageTitle>Not found</PageTitle>
<p role="alert">Sorry, there's nothing at this address.</p> <LayoutView Layout="@typeof(MainLayout)">
</LayoutView> <p role="alert">Sorry, there's nothing at this address.</p>
</NotFound> </LayoutView>
</Router> </NotFound>
</Router>
</CascadingAuthenticationState>

13
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" });
}
}

2
ErsatzTV/ErsatzTV.csproj

@ -12,6 +12,7 @@
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract> <IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<Configurations>Debug;Release;Debug No Sync</Configurations> <Configurations>Debug;Release;Debug No Sync</Configurations>
<Platforms>AnyCPU</Platforms> <Platforms>AnyCPU</Platforms>
<UserSecretsId>bf31217d-f4ec-4520-8cc3-138059044ede</UserSecretsId>
</PropertyGroup> </PropertyGroup>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish"> <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
@ -60,6 +61,7 @@
<PackageReference Include="Markdig" Version="0.30.4" /> <PackageReference Include="Markdig" Version="0.30.4" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" /> <PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" /> <PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.2" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.2" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.2" /> <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.2"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.2">

20
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; }
}

103
ErsatzTV/Shared/MainLayout.razor

@ -3,8 +3,8 @@
@using ErsatzTV.Extensions @using ErsatzTV.Extensions
@using ErsatzTV.Application.Search @using ErsatzTV.Application.Search
@implements IDisposable @implements IDisposable
@inject NavigationManager _navigationManager @inject NavigationManager NavigationManager
@inject IMediator _mediator @inject IMediator Mediator
<MudThemeProvider Theme="_ersatzTvTheme"/> <MudThemeProvider Theme="_ersatzTvTheme"/>
<MudDialogProvider DisableBackdropClick="true"/> <MudDialogProvider DisableBackdropClick="true"/>
@ -17,49 +17,51 @@
<img src="images/ersatztv.png" alt="ErsatzTV"/> <img src="images/ersatztv.png" alt="ErsatzTV"/>
</a> </a>
</div> </div>
<EditForm Model="@_dummyModel" OnSubmit="@(_ => PerformSearch())"> <div class="search-form">
<MudTextField T="string" <EditForm Model="@_dummyModel" OnSubmit="@(_ => PerformSearch())">
@bind-Value="@Query" <MudTextField T="string"
AdornmentIcon="@Icons.Material.Filled.Search" @bind-Value="@Query"
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search"
Variant="Variant.Outlined" Adornment="Adornment.Start"
Immediate="true" Variant="Variant.Outlined"
Class="search-bar" Immediate="true"
@onclick="@(() => _isOpen = true)" Class="search-bar"
OnKeyUp="OnKeyUp"> @onclick="@(() => _isOpen = true)"
</MudTextField> OnKeyUp="OnKeyUp">
<MudPopover Open="@_isOpen" MaxHeight="300" AnchorOrigin="Origin.BottomCenter" TransformOrigin="Origin.TopCenter" RelativeWidth="true"> </MudTextField>
@if (!string.IsNullOrWhiteSpace(_query) && _query.Length >= 3) <MudPopover Open="@_isOpen" MaxHeight="300" AnchorOrigin="Origin.BottomCenter" TransformOrigin="Origin.TopCenter" RelativeWidth="true">
{ @if (!string.IsNullOrWhiteSpace(_query) && _query.Length >= 3)
var matches = _searchTargets.Where(s => s.Name.Contains(_query, StringComparison.CurrentCultureIgnoreCase)).ToList();
if (matches.Any())
{ {
<MudList Clickable="true" Dense="true"> var matches = _searchTargets.Where(s => s.Name.Contains(_query, StringComparison.CurrentCultureIgnoreCase)).ToList();
@foreach (SearchTargetViewModel searchTarget in matches) if (matches.Any())
{ {
<MudListItem @key="@searchTarget" OnClick="@(() => NavigateTo(searchTarget))"> <MudList Clickable="true" Dense="true">
<MudText Typo="Typo.body1">@searchTarget.Name</MudText> @foreach (SearchTargetViewModel searchTarget in matches)
<MudText Typo="Typo.subtitle1" Class="mud-text-disabled"> {
@(searchTarget.Kind switch <MudListItem @key="@searchTarget" OnClick="@(() => NavigateTo(searchTarget))">
{ <MudText Typo="Typo.body1">@searchTarget.Name</MudText>
SearchTargetKind.Channel => "Channel", <MudText Typo="Typo.subtitle1" Class="mud-text-disabled">
SearchTargetKind.FFmpegProfile => "FFmpeg Profile", @(searchTarget.Kind switch
SearchTargetKind.ChannelWatermark => "Channel Watermark", {
SearchTargetKind.Collection => "Collection", SearchTargetKind.Channel => "Channel",
SearchTargetKind.MultiCollection => "Multi Collection", SearchTargetKind.FFmpegProfile => "FFmpeg Profile",
SearchTargetKind.SmartCollection => "Smart Collection", SearchTargetKind.ChannelWatermark => "Channel Watermark",
SearchTargetKind.Schedule => "Schedule", SearchTargetKind.Collection => "Collection",
SearchTargetKind.ScheduleItems => "Schedule Items", SearchTargetKind.MultiCollection => "Multi Collection",
_ => string.Empty SearchTargetKind.SmartCollection => "Smart Collection",
}) SearchTargetKind.Schedule => "Schedule",
</MudText> SearchTargetKind.ScheduleItems => "Schedule Items",
</MudListItem> _ => string.Empty
} })
</MudList> </MudText>
</MudListItem>
}
</MudList>
}
} }
} </MudPopover>
</MudPopover> </EditForm>
</EditForm> </div>
<MudSpacer/> <MudSpacer/>
<MudLink Color="Color.Info" Href="iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink> <MudLink Color="Color.Info" Href="iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
<MudLink Color="Color.Info" Href="iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink> <MudLink Color="Color.Info" Href="iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
@ -73,6 +75,13 @@
<MudTooltip Text="GitHub"> <MudTooltip Text="GitHub">
<MudIconButton Icon="@Icons.Custom.Brands.GitHub" Color="Color.Primary" Link="https://github.com/jasongdove/ErsatzTV" Target="_blank"/> <MudIconButton Icon="@Icons.Custom.Brands.GitHub" Color="Color.Primary" Link="https://github.com/jasongdove/ErsatzTV" Target="_blank"/>
</MudTooltip> </MudTooltip>
<AuthorizeView>
<form action="/account/logout" method="post">
<MudTooltip Text="Logout">
<MudIconButton Icon="@Icons.Material.Filled.Logout" Color="Color.Secondary" ButtonType="ButtonType.Submit"/>
</MudTooltip>
</form>
</AuthorizeView>
</MudAppBar> </MudAppBar>
<MudDrawer Open="true" Elevation="2" ClipMode="DrawerClipMode.Always"> <MudDrawer Open="true" Elevation="2" ClipMode="DrawerClipMode.Always">
<MudNavMenu> <MudNavMenu>
@ -176,17 +185,17 @@
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
await base.OnParametersSetAsync(); await base.OnParametersSetAsync();
_query = _navigationManager.Uri.GetSearchQuery(); _query = NavigationManager.Uri.GetSearchQuery();
if (_searchTargets is null) if (_searchTargets is null)
{ {
_searchTargets = await _mediator.Send(new QuerySearchTargets(), _cts.Token); _searchTargets = await Mediator.Send(new QuerySearchTargets(), _cts.Token);
} }
} }
private void PerformSearch() private void PerformSearch()
{ {
_navigationManager.NavigateTo(_query.GetRelativeSearchQuery(), true); NavigationManager.NavigateTo(_query.GetRelativeSearchQuery(), true);
StateHasChanged(); StateHasChanged();
} }
@ -206,7 +215,7 @@
private void NavigateTo(SearchTargetViewModel searchTarget) => private void NavigateTo(SearchTargetViewModel searchTarget) =>
// need to force smart collections to navigate since the query string is all that differs // 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) => private string UrlFor(SearchTargetViewModel searchTarget) =>
searchTarget.Kind switch searchTarget.Kind switch

67
ErsatzTV/Startup.cs

@ -60,6 +60,7 @@ using FluentValidation.AspNetCore;
using Ganss.Xss; using Ganss.Xss;
using MediatR; using MediatR;
using MediatR.Courier.DependencyInjection; using MediatR.Courier.DependencyInjection;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -118,6 +119,43 @@ public class Startup
#endif #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( services.AddCors(
o => o.AddPolicy( o => o.AddPolicy(
"AllowAll", "AllowAll",
@ -149,14 +187,23 @@ public class Startup
services.AddFluentValidationAutoValidation(); services.AddFluentValidationAutoValidation();
services.AddValidatorsFromAssemblyContaining<Startup>(); services.AddValidatorsFromAssemblyContaining<Startup>();
if (!CurrentEnvironment.IsDevelopment()) string v2 = Environment.GetEnvironmentVariable("ETV_UI_V2");
if (!CurrentEnvironment.IsDevelopment() && !string.IsNullOrWhiteSpace(v2))
{ {
services.AddSpaStaticFiles(options => options.RootPath = "wwwroot/v2"); services.AddSpaStaticFiles(options => options.RootPath = "wwwroot/v2");
} }
services.AddMemoryCache(); services.AddMemoryCache();
services.AddRazorPages(); services.AddRazorPages(
options =>
{
if (OidcHelper.IsEnabled)
{
options.Conventions.AuthorizeFolder("/");
}
});
services.AddServerSideBlazor(); services.AddServerSideBlazor();
services.AddMudServices(); services.AddMudServices();
@ -318,7 +365,14 @@ public class Startup
app.UseRouting(); app.UseRouting();
if (!env.IsDevelopment()) if (OidcHelper.IsEnabled)
{
app.UseAuthentication();
app.UseAuthorization();
}
string v2 = Environment.GetEnvironmentVariable("ETV_UI_V2");
if (!env.IsDevelopment() && !string.IsNullOrWhiteSpace(v2))
{ {
app.Map( app.Map(
"/v2", "/v2",
@ -330,6 +384,13 @@ public class Startup
} }
app2.UseRouting(); app2.UseRouting();
if (OidcHelper.IsEnabled)
{
app.UseAuthentication();
app.UseAuthorization();
}
app2.UseEndpoints(e => e.MapFallbackToFile("index.html")); app2.UseEndpoints(e => e.MapFallbackToFile("index.html"));
app2.UseFileServer( app2.UseFileServer(
new FileServerOptions new FileServerOptions

2
ErsatzTV/wwwroot/css/site.css

@ -74,7 +74,7 @@
border-radius: 4px; border-radius: 4px;
} }
.app-bar form { flex-grow: 1; } .app-bar .search-form { flex-grow: 1; }
.fanart-container { .fanart-container {
position: relative; position: relative;

Loading…
Cancel
Save