Browse Source

JWT Query Parameter Auth for IPTV Links (#1215)

* JWT Auth

* Standardized url variable additions

* formatting and minor refactoring

* this isn't needed

* allow channel logos without auth

* update changelog

---------

Co-authored-by: Ministorm3 <4474921+Ministorm3@users.noreply.github.com>
pull/1216/head
Jason Dove 2 years ago committed by GitHub
parent
commit
9ba0b844a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      CHANGELOG.md
  2. 4
      ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs
  3. 5
      ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs
  4. 4
      ErsatzTV.Application/Channels/Queries/GetChannelPlaylist.cs
  5. 4
      ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs
  6. 18
      ErsatzTV.Core/Iptv/ChannelGuide.cs
  7. 28
      ErsatzTV.Core/Iptv/ChannelPlaylist.cs
  8. 31
      ErsatzTV/Controllers/IptvController.cs
  9. 1
      ErsatzTV/ErsatzTV.csproj
  10. 25
      ErsatzTV/Filters/ConditionalIptvAuthorizeFilter.cs
  11. 20
      ErsatzTV/JwtHelper.cs
  12. 2
      ErsatzTV/Shared/MainLayout.razor
  13. 70
      ErsatzTV/Startup.cs

7
CHANGELOG.md

@ -8,6 +8,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -8,6 +8,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `Troubleshooting` page with aggregated settings/hardware accel info for easy reference
- Read `director` fields from music video NFO metadata
- Pass `directors` and `studios` to music video credit templates
- Add optional JSON Web Token (JWT) query string auth for streaming endpoints
- This can be configured using the following env var (note the double underscore separator `__`)
- `JWT__ISSUERSIGNINGKEY`
- When configured, a JWT signed with the configured signing key is required to be passed in the query string as `access_token`, for example:
- `http://localhost:8409/iptv/channels.m3u?access_token=ABCDEF`
- `http://localhost:8409/iptv/xmltv.xml?access_token=ABCDEF`
- When channels are retrieved this way, the access token will automatically be passed through to all necessary urls
### Fixed
- Fix scaling anamorphic content from non-local libraries

4
ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
using ErsatzTV.Core.Iptv;
using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels;
public record GetChannelGuide(string Scheme, string Host, string BaseUrl) : IRequest<ChannelGuide>;
public record GetChannelGuide(string Scheme, string Host, string BaseUrl, string AccessToken) : IRequest<ChannelGuide>;

5
ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using Microsoft.IO;
@ -25,5 +25,6 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGu @@ -25,5 +25,6 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGu
request.Scheme,
request.Host,
request.BaseUrl,
channels));
channels,
request.AccessToken));
}

4
ErsatzTV.Application/Channels/Queries/GetChannelPlaylist.cs

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
using ErsatzTV.Core.Iptv;
using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels;
public record GetChannelPlaylist(string Scheme, string Host, string BaseUrl, string Mode) : IRequest<ChannelPlaylist>;
public record GetChannelPlaylist(string Scheme, string Host, string BaseUrl, string Mode, string AccessToken) : IRequest<ChannelPlaylist>;

4
ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
@ -14,7 +14,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha @@ -14,7 +14,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
_channelRepository.GetAll()
.Map(channels => EnsureMode(channels, request.Mode))
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, request.BaseUrl, channels));
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, request.BaseUrl, channels, request.AccessToken));
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{

18
ErsatzTV.Core/Iptv/ChannelGuide.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using System.Text;
using System.Text;
using System.Xml;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
@ -16,19 +16,27 @@ public class ChannelGuide @@ -16,19 +16,27 @@ public class ChannelGuide
private readonly string _baseUrl;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
private readonly string _scheme;
private readonly string _accessTokenUri;
public ChannelGuide(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
string scheme,
string host,
string baseUrl,
List<Channel> channels)
List<Channel> channels,
string accessToken)
{
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_scheme = scheme;
_host = host;
_baseUrl = baseUrl;
_channels = channels;
_accessTokenUri = string.Empty;
if (!string.IsNullOrWhiteSpace(accessToken))
{
_accessTokenUri = $"?access_token={accessToken}";
}
}
public string ToXml()
@ -77,8 +85,8 @@ public class ChannelGuide @@ -77,8 +85,8 @@ public class ChannelGuide
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}{_baseUrl}/iptv/logos/{artwork.Path}.jpg",
() => $"{_scheme}://{_host}{_baseUrl}/iptv/images/ersatztv-500.png");
artwork => $"{_scheme}://{_host}{_baseUrl}/iptv/logos/{artwork.Path}.jpg{_accessTokenUri}",
() => $"{_scheme}://{_host}{_baseUrl}/iptv/images/ersatztv-500.png{_accessTokenUri}");
xml.WriteAttributeString("src", logo);
xml.WriteEndElement(); // icon
@ -405,7 +413,7 @@ public class ChannelGuide @@ -405,7 +413,7 @@ public class ChannelGuide
_ => "posters"
};
artworkPath = $"{_scheme}://{_host}{_baseUrl}/iptv/artwork/{artworkFolder}/{artwork.Path}.jpg";
artworkPath = $"{_scheme}://{_host}{_baseUrl}/iptv/artwork/{artworkFolder}/{artwork.Path}.jpg{_accessTokenUri}";
}
return artworkPath;

28
ErsatzTV.Core/Iptv/ChannelPlaylist.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using System.Text;
using System.Text;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Iptv;
@ -9,20 +9,30 @@ public class ChannelPlaylist @@ -9,20 +9,30 @@ public class ChannelPlaylist
private readonly string _host;
private readonly string _baseUrl;
private readonly string _scheme;
private readonly string _accessToken;
public ChannelPlaylist(string scheme, string host, string baseUrl, List<Channel> channels)
public ChannelPlaylist(string scheme, string host, string baseUrl, List<Channel> channels, string accessToken)
{
_scheme = scheme;
_host = host;
_baseUrl = baseUrl;
_channels = channels;
_accessToken = accessToken;
}
public string ToM3U()
{
var sb = new StringBuilder();
string accessTokenUri = string.Empty;
string accessTokenUriAmp = string.Empty;
if (_accessToken != null)
{
accessTokenUri = $"?access_token={_accessToken}";
accessTokenUriAmp = $"&access_token={_accessToken}";
}
string xmltv = $"{_scheme}://{_host}{_baseUrl}/iptv/xmltv.xml";
var xmltv = $"{_scheme}://{_host}{_baseUrl}/iptv/xmltv.xml{accessTokenUri}";
sb.AppendLine($"#EXTM3U url-tvg=\"{xmltv}\" x-tvg-url=\"{xmltv}\"");
foreach (Channel channel in _channels.OrderBy(c => decimal.Parse(c.Number)))
{
@ -30,8 +40,8 @@ public class ChannelPlaylist @@ -30,8 +40,8 @@ public class ChannelPlaylist
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}{_baseUrl}/iptv/logos/{artwork.Path}.jpg",
() => $"{_scheme}://{_host}{_baseUrl}/iptv/images/ersatztv-500.png");
artwork => $"{_scheme}://{_host}{_baseUrl}/iptv/logos/{artwork.Path}.jpg{accessTokenUri}",
() => $"{_scheme}://{_host}{_baseUrl}/iptv/images/ersatztv-500.png{accessTokenUri}");
string shortUniqueId = Convert.ToBase64String(channel.UniqueId.ToByteArray())
.TrimEnd('=')
@ -40,10 +50,10 @@ public class ChannelPlaylist @@ -40,10 +50,10 @@ public class ChannelPlaylist
string format = channel.StreamingMode switch
{
StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct",
StreamingMode.HttpLiveStreamingSegmenter => "m3u8?mode=segmenter",
StreamingMode.TransportStreamHybrid => "ts",
_ => "ts?mode=ts-legacy"
StreamingMode.HttpLiveStreamingDirect => $"m3u8?mode=hls-direct{accessTokenUriAmp}",
StreamingMode.HttpLiveStreamingSegmenter => $"m3u8?mode=segmenter{accessTokenUriAmp}",
StreamingMode.TransportStreamHybrid => $"ts{accessTokenUri}",
_ => $"ts?mode=ts-legacy{accessTokenUriAmp}"
};
string vcodec = channel.FFmpegProfile.VideoFormat.ToString().ToLowerInvariant();

31
ErsatzTV/Controllers/IptvController.cs

@ -9,6 +9,7 @@ using ErsatzTV.Core.Errors; @@ -9,6 +9,7 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@ -16,6 +17,7 @@ namespace ErsatzTV.Controllers; @@ -16,6 +17,7 @@ namespace ErsatzTV.Controllers;
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[ServiceFilter(typeof(ConditionalIptvAuthorizeFilter))]
public class IptvController : ControllerBase
{
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
@ -36,12 +38,23 @@ public class IptvController : ControllerBase @@ -36,12 +38,23 @@ public class IptvController : ControllerBase
public Task<IActionResult> GetChannelPlaylist(
[FromQuery]
string mode = "mixed") =>
_mediator.Send(new GetChannelPlaylist(Request.Scheme, Request.Host.ToString(), Request.PathBase, mode))
_mediator.Send(
new GetChannelPlaylist(
Request.Scheme,
Request.Host.ToString(),
Request.PathBase,
mode,
Request.Query["access_token"]))
.Map<ChannelPlaylist, IActionResult>(Ok);
[HttpGet("iptv/xmltv.xml")]
public Task<IActionResult> GetGuide() =>
_mediator.Send(new GetChannelGuide(Request.Scheme, Request.Host.ToString(), Request.PathBase))
_mediator.Send(
new GetChannelGuide(
Request.Scheme,
Request.Host.ToString(),
Request.PathBase,
Request.Query["access_token"]))
.Map<ChannelGuide, IActionResult>(Ok);
[HttpGet("iptv/hdhr/channel/{channelNumber}.ts")]
@ -77,7 +90,7 @@ public class IptvController : ControllerBase @@ -77,7 +90,7 @@ public class IptvController : ControllerBase
mode = "ts";
break;
default:
return Redirect($"~/iptv/channel/{channelNumber}.m3u8");
return Redirect($"~/iptv/channel/{channelNumber}.m3u8{AccessTokenQuery()}");
}
}
}
@ -126,7 +139,7 @@ public class IptvController : ControllerBase @@ -126,7 +139,7 @@ public class IptvController : ControllerBase
public async Task<IActionResult> GetLivePlaylist(string channelNumber, CancellationToken cancellationToken)
{
// _logger.LogDebug("Checking for session worker for channel {Channel}", channelNumber);
if (_ffmpegSegmenterService.SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
{
// _logger.LogDebug("Trimming playlist for channel {Channel}", channelNumber);
@ -168,7 +181,7 @@ public class IptvController : ControllerBase @@ -168,7 +181,7 @@ public class IptvController : ControllerBase
mode = "segmenter";
break;
default:
return Redirect($"~/iptv/channel/{channelNumber}.ts");
return Redirect($"~/iptv/channel/{channelNumber}.ts{AccessTokenQuery()}");
}
}
}
@ -196,7 +209,7 @@ public class IptvController : ControllerBase @@ -196,7 +209,7 @@ public class IptvController : ControllerBase
"Session is already active; returning multi-variant playlist for channel {Channel}",
channelNumber);
return Content(GetMultiVariantPlaylist(channelNumber), "application/x-mpegurl");
// return RedirectPreserveMethod($"iptv/session/{channelNumber}/hls.m3u8");
// return RedirectPreserveMethod($"iptv/session/{channelNumber}/hls.m3u8");
default:
_logger.LogWarning(
"Failed to start segmenter for channel {ChannelNumber}: {Error}",
@ -236,5 +249,9 @@ public class IptvController : ControllerBase @@ -236,5 +249,9 @@ public class IptvController : ControllerBase
$@"#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=10000000
{Request.Scheme}://{Request.Host}/iptv/session/{channelNumber}/hls.m3u8";
{Request.Scheme}://{Request.Host}/iptv/session/{channelNumber}/hls.m3u8{AccessTokenQuery()}";
private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"])
? string.Empty
: $"?access_token={Request.Query["access_token"]}";
}

1
ErsatzTV/ErsatzTV.csproj

@ -60,6 +60,7 @@ @@ -60,6 +60,7 @@
<PackageReference Include="LanguageExt.Core" Version="4.4.2" />
<PackageReference Include="Markdig" Version="0.31.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.3" />

25
ErsatzTV/Filters/ConditionalIptvAuthorizeFilter.cs

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.AspNetCore.Mvc.Filters;
namespace ErsatzTV.Filters;
public class ConditionalIptvAuthorizeFilter : AuthorizeFilter
{
public ConditionalIptvAuthorizeFilter(string policy) : base(
new AuthorizationPolicyBuilder().RequireAuthenticatedUser().AddAuthenticationSchemes("jwt")
.RequireAssertion(_ => JwtHelper.IsEnabled).Build())
{
}
public override Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
// allow logos through without authorization, since they're also used in the management ui
if (JwtHelper.IsEnabled && !context.HttpContext.Request.Path.StartsWithSegments("/iptv/logos"))
{
return base.OnAuthorizationAsync(context);
}
return Task.CompletedTask;
}
}

20
ErsatzTV/JwtHelper.cs

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace ErsatzTV;
public static class JwtHelper
{
public static void Init(IConfiguration configuration)
{
string issuerSigningKey = configuration["JWT:IssuerSigningKey"];
IsEnabled = !string.IsNullOrWhiteSpace(issuerSigningKey);
if (IsEnabled)
{
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(issuerSigningKey!));
}
}
public static SymmetricSecurityKey IssuerSigningKey { get; private set; }
public static bool IsEnabled { get; private set; }
}

2
ErsatzTV/Shared/MainLayout.razor

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@using System.Reflection
@using ErsatzTV.Extensions
@using ErsatzTV.Application.Search

70
ErsatzTV/Startup.cs

@ -34,6 +34,7 @@ using ErsatzTV.Core.Trakt; @@ -34,6 +34,7 @@ using ErsatzTV.Core.Trakt;
using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Pipeline;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Filters;
using ErsatzTV.Formatters;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
@ -60,12 +61,16 @@ using FluentValidation.AspNetCore; @@ -60,12 +61,16 @@ using FluentValidation.AspNetCore;
using Ganss.Xss;
using MediatR.Courier.DependencyInjection;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IO;
using MudBlazor.Services;
using Newtonsoft.Json;
@ -121,6 +126,7 @@ public class Startup @@ -121,6 +126,7 @@ public class Startup
});
OidcHelper.Init(Configuration);
JwtHelper.Init(Configuration);
if (OidcHelper.IsEnabled)
{
@ -178,6 +184,66 @@ public class Startup @@ -178,6 +184,66 @@ public class Startup
});
}
if (JwtHelper.IsEnabled)
{
services.AddAuthentication().AddJwtBearer(
"jwt",
options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = JwtHelper.IssuerSigningKey,
ValidateLifetime = true
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = static context =>
{
StringValues token = context.Request.Query["access_token"];
if (!string.IsNullOrWhiteSpace(token))
{
context.Token = token;
}
return Task.CompletedTask;
}
};
});
}
if (OidcHelper.IsEnabled || JwtHelper.IsEnabled)
{
services.AddAuthorization(
options =>
{
if (OidcHelper.IsEnabled)
{
var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder(
"cookie",
"oidc");
defaultAuthorizationPolicyBuilder =
defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser();
options.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build();
}
if (JwtHelper.IsEnabled)
{
var onlyJwtSchemePolicyBuilder = new AuthorizationPolicyBuilder("jwt");
options.AddPolicy(
"JwtOnlyScheme",
onlyJwtSchemePolicyBuilder
.RequireAuthenticatedUser()
.Build());
}
}
);
}
services.AddCors(
o => o.AddPolicy(
"AllowAll",
@ -206,6 +272,8 @@ public class Startup @@ -206,6 +272,8 @@ public class Startup
opt.SerializerSettings.Converters.Add(new StringEnumConverter());
});
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
services.AddFluentValidationAutoValidation();
services.AddValidatorsFromAssemblyContaining<Startup>();
@ -386,7 +454,7 @@ public class Startup @@ -386,7 +454,7 @@ public class Startup
});
app.UseRouting();
if (OidcHelper.IsEnabled)
{
app.UseAuthentication();

Loading…
Cancel
Save