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 3 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. 27
      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. 68
      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/).
- Add `Troubleshooting` page with aggregated settings/hardware accel info for easy reference - Add `Troubleshooting` page with aggregated settings/hardware accel info for easy reference
- Read `director` fields from music video NFO metadata - Read `director` fields from music video NFO metadata
- Pass `directors` and `studios` to music video credit templates - 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 ### Fixed
- Fix scaling anamorphic content from non-local libraries - Fix scaling anamorphic content from non-local libraries

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

@ -1,5 +1,5 @@
using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels; 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 @@
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Iptv;
using Microsoft.IO; using Microsoft.IO;
@ -25,5 +25,6 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGu
request.Scheme, request.Scheme,
request.Host, request.Host,
request.BaseUrl, request.BaseUrl,
channels)); channels,
request.AccessToken));
} }

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

@ -1,5 +1,5 @@
using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels; 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 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Iptv;
@ -14,7 +14,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) => public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
_channelRepository.GetAll() _channelRepository.GetAll()
.Map(channels => EnsureMode(channels, request.Mode)) .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) private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{ {

18
ErsatzTV.Core/Iptv/ChannelGuide.cs

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

28
ErsatzTV.Core/Iptv/ChannelPlaylist.cs

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

27
ErsatzTV/Controllers/IptvController.cs

@ -9,6 +9,7 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Iptv;
using ErsatzTV.Filters;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -16,6 +17,7 @@ namespace ErsatzTV.Controllers;
[ApiController] [ApiController]
[ApiExplorerSettings(IgnoreApi = true)] [ApiExplorerSettings(IgnoreApi = true)]
[ServiceFilter(typeof(ConditionalIptvAuthorizeFilter))]
public class IptvController : ControllerBase public class IptvController : ControllerBase
{ {
private readonly IFFmpegSegmenterService _ffmpegSegmenterService; private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
@ -36,12 +38,23 @@ public class IptvController : ControllerBase
public Task<IActionResult> GetChannelPlaylist( public Task<IActionResult> GetChannelPlaylist(
[FromQuery] [FromQuery]
string mode = "mixed") => 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); .Map<ChannelPlaylist, IActionResult>(Ok);
[HttpGet("iptv/xmltv.xml")] [HttpGet("iptv/xmltv.xml")]
public Task<IActionResult> GetGuide() => 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); .Map<ChannelGuide, IActionResult>(Ok);
[HttpGet("iptv/hdhr/channel/{channelNumber}.ts")] [HttpGet("iptv/hdhr/channel/{channelNumber}.ts")]
@ -77,7 +90,7 @@ public class IptvController : ControllerBase
mode = "ts"; mode = "ts";
break; break;
default: default:
return Redirect($"~/iptv/channel/{channelNumber}.m3u8"); return Redirect($"~/iptv/channel/{channelNumber}.m3u8{AccessTokenQuery()}");
} }
} }
} }
@ -168,7 +181,7 @@ public class IptvController : ControllerBase
mode = "segmenter"; mode = "segmenter";
break; break;
default: default:
return Redirect($"~/iptv/channel/{channelNumber}.ts"); return Redirect($"~/iptv/channel/{channelNumber}.ts{AccessTokenQuery()}");
} }
} }
} }
@ -236,5 +249,9 @@ public class IptvController : ControllerBase
$@"#EXTM3U $@"#EXTM3U
#EXT-X-VERSION:3 #EXT-X-VERSION:3
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=10000000 #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 @@
<PackageReference Include="LanguageExt.Core" Version="4.4.2" /> <PackageReference Include="LanguageExt.Core" Version="4.4.2" />
<PackageReference Include="Markdig" Version="0.31.0" /> <PackageReference Include="Markdig" Version="0.31.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.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.Authentication.OpenIdConnect" Version="7.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.3" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.3" /> <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.3" />

25
ErsatzTV/Filters/ConditionalIptvAuthorizeFilter.cs

@ -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 @@
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 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@using System.Reflection @using System.Reflection
@using ErsatzTV.Extensions @using ErsatzTV.Extensions
@using ErsatzTV.Application.Search @using ErsatzTV.Application.Search

68
ErsatzTV/Startup.cs

@ -34,6 +34,7 @@ using ErsatzTV.Core.Trakt;
using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Pipeline; using ErsatzTV.FFmpeg.Pipeline;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Filters;
using ErsatzTV.Formatters; using ErsatzTV.Formatters;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Infrastructure.Data.Repositories;
@ -60,12 +61,16 @@ using FluentValidation.AspNetCore;
using Ganss.Xss; using Ganss.Xss;
using MediatR.Courier.DependencyInjection; using MediatR.Courier.DependencyInjection;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IO; using Microsoft.IO;
using MudBlazor.Services; using MudBlazor.Services;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -121,6 +126,7 @@ public class Startup
}); });
OidcHelper.Init(Configuration); OidcHelper.Init(Configuration);
JwtHelper.Init(Configuration);
if (OidcHelper.IsEnabled) if (OidcHelper.IsEnabled)
{ {
@ -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( services.AddCors(
o => o.AddPolicy( o => o.AddPolicy(
"AllowAll", "AllowAll",
@ -206,6 +272,8 @@ public class Startup
opt.SerializerSettings.Converters.Add(new StringEnumConverter()); opt.SerializerSettings.Converters.Add(new StringEnumConverter());
}); });
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
services.AddFluentValidationAutoValidation(); services.AddFluentValidationAutoValidation();
services.AddValidatorsFromAssemblyContaining<Startup>(); services.AddValidatorsFromAssemblyContaining<Startup>();

Loading…
Cancel
Save