We are in the process of upgrading a client from Umbraco 8 to 10.
As part of the Umbraco 8 site, we have a custom media controller, which checks for a specific document type (and a specific property on the document type) in order to secure media that shouldn't be available if a user is not logged on to the Intranet (so this is front-end security, not back office - any file that's not of this specific document type is returned immediately to avoid issues with media etc).
In Umbraco 8 this was fairly simple to set up - we had a component which registered the new media controller on the route, and all was fine.
However, I'm a bit flummoxed as to how to do this in Umbraco 10; I've set up a custom route for /media/index (I've followed this article: https://our.umbraco.com/documentation/Reference/Routing/Custom-Routes/ using the IVirtualPageController approach, but this seems to be more designed for returning a whole page rather than a single item; I've tried making the Controller an IComponent but that won't work because they are scoped as Singletons and we need to access scope-level services).
I'm aware of the existence of Media Protect - unfortunately that won't work for our use case, because we need to protect the media by a document type and property rather than the location of the item.
Has anybody managed to successfully implement something like this in Umbraco 9/10? Perhaps there's a better way than overriding the Media controller?
Yes I do something similar, I created a middleware control which is then registered in startup. I'm not near my computer at the moment but will post my code tomorrow for you to look at.
This is the code for my middleware, it isn't exactly what you want but should get you started. Basically I have a usergroup picker on medi files/folders to restrict access for certain media files. The code below checks this against the current user to see if they can access the file, if not it responds with a 401 status.
public class ProtectedMediaHandlerMiddleware
{
private readonly RequestDelegate _next;
public ProtectedMediaHandlerMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context,IMediaService mediaService,IMemberManager memberManager, IOptionsSnapshot<CookieAuthenticationOptions> cookieOptionsSnapshot, IMemberGroupService memberGroupService)
{
if (!context.Request.Path.StartsWithSegments("/media"))
{
await _next(context);
return;
}
if (IsBackofficeUser(context, cookieOptionsSnapshot))
{
await _next(context);
return;
}
var protectedFolder = false;
IMedia? parent = null;
IMedia? mediaItem = mediaService.GetMediaByPath(context.Request.Path);
if (mediaItem == null)
{
await _next(context);
return;
}
parent = mediaService.GetParent(mediaItem.Id);
var isAllowed = false;
while (parent != null)
{
if (parent.Properties.TryGetValue("authorisedRoles", out IProperty prop))
{
var res = prop.Values.Where(p => p.EditedValue != null).Select(p => p.EditedValue.ToString()).FirstOrDefault();
if (!string.IsNullOrEmpty(res))
{
var allowedFolderGroupStrs = res.Split(',',StringSplitOptions.RemoveEmptyEntries);
protectedFolder = allowedFolderGroupStrs.Any();
var member = memberManager.GetCurrentMemberAsync().Result;
if(member != null)
{
var roles = memberManager.GetRolesAsync(member).Result;
foreach (var memberGroupStr in allowedFolderGroupStrs)
{
var memberGroup = memberGroupService.GetById(Convert.ToInt32(memberGroupStr));
if (memberGroup != null)
{
isAllowed = roles.Contains(memberGroup.Name);
}
if (isAllowed)
{
break;
}
}
}
}
}
parent = mediaService.GetParent(parent.Id);
}
if (!protectedFolder)
{
await _next(context);
return;
}
if (memberManager.IsLoggedIn() && isAllowed)
{
await _next(context);
return;
}
// Stop processing the request and return a 401 Unauthorized response.
context.Response.Clear();
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
private bool IsBackofficeUser(HttpContext context, IOptionsSnapshot<CookieAuthenticationOptions> cookieOptionsSnapshot)
{
CookieAuthenticationOptions cookieOptions = cookieOptionsSnapshot.Get(Umbraco.Cms.Core.Constants.Security.BackOfficeAuthenticationType);
string backOfficeCookie = context.Request.Cookies[cookieOptions.Cookie.Name!];
AuthenticationTicket unprotected = cookieOptions.TicketDataFormat.Unprotect(backOfficeCookie!);
ClaimsIdentity backOfficeIdentity = new ClaimsIdentity();
if (unprotected != null)
{
backOfficeIdentity = unprotected!.Principal.GetUmbracoIdentity();
}
return backOfficeIdentity.IsAuthenticated;
}
}
public static class MiddleWareExtensions
{
public static IApplicationBuilder ProtectedMediaHandler(this IApplicationBuilder builder)
{
return builder.UseMiddleware<ProtectedMediaHandlerMiddleware>();
}
}
Then in your startup.cs you can add app.ProtectedMediaHandler(); to register it.
I'd managed to get about as far as that (with the realisation I'd have to use the IMediaService rather than a cache) - my concern is with the IMediaService - I think the default implementation goes directly to the database (this is a very large site with ~100k media items and it has a public site too, which can't slow down while we do this check - the "old" way of getting media via a controller let me use the cache, which was much quicker).
That said we're only interested in doing this for non-image files, so hopefully I can filter those requests out via the path and just let the normal MiddleWare handle it :)
if you can do it based on type/path then you won't need to get the media itself so would be much quicker. If you do need the Umbraco object for the file, you should use umbracocontext, but not sure how you would do that based only on a path (you can with the media service)
Yes, I think that's my problem - I need the object to check the properties of the MediaType so I can check a) if it's the right sort of media and b) if it's set to Internal.
I can't get that without going to the MediaService at this point in the pipeline (but I can filter out images via the path and all of these documents should be in one of a couple of folders so I could possibly check for that too, so that might be the most appropriate solution - we only want to go to the media service if we know we need to, if that makes sense.).
I've asked Umbraco Support (we're a Gold Partner) if there's some way of overriding the route as there was in Umbraco 8 - I'll post here if they come back to me, but in the meantime thanks for confirming my suspicions about how I'd have to do it :)
So I got a response from Umbraco Support and after a bit of effort I've got it mostly working (it doesn't seem to hit the code on every request at the moment), but it's...quite involved...
(We have a very simple use case for this, fortunately - there's only one member group and users can only log on if they're in that group, so if they're logged on at all they're OK to access an internal document)
public class ProtectedMediaMiddleware: IMiddleware
{
private readonly IPublishedSnapshotService _snapshotService;
private readonly IMemberManager _memberManager;
private readonly IUmbracoContextFactory _umbracoContextFactory;
public ProtectedMediaMiddleware(IPublishedSnapshotService snapshotService, IMemberManager memberManager, IUmbracoContextFactory umbracoContextFactory)
{
_snapshotService = snapshotService;
_memberManager = memberManager;
_umbracoContextFactory = umbracoContextFactory;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
//If its not media, or it is media but it's an image, or it's a back office request, move on:
if (!context.Request.Path.StartsWithSegments("/media") || context.Request.Headers.ContentType.Contains("image") || context.Request.IsBackOfficeRequest())
{
await next(context);
return;
}
try
{
using var _ = _umbracoContextFactory.EnsureUmbracoContext();
var snapshot = _snapshotService.CreatePublishedSnapshot(null);
var mediaCache = snapshot.Media;
var mediaXpath = "//internalDocument[@isDoc]";
var allMedia = snapshot.Media.GetByXPath(mediaXpath).Cast<InternalDocument>().ToList();
//var media = snapshot.Media.GetSingleByXPath(mediaXpath);
if (allMedia.Any(x => x.Url().Contains(context.Request.Path)))
{
var media = allMedia.FirstOrDefault(x => x.Url() == context.Request.Path);
if (media != null)
{
if (media.IsDocumentType("internalDocument"))
{
if (media.Value<string>("Access").Contains("Internal") && !_memberManager.IsLoggedIn())
{
// Stop processing the request and return a 401 response.
context.Response.StatusCode = 401;
await Task.FromResult(0);
return;
}
}
}
}
}
catch(Exception ex)
{
//swallow the exception - we want to continue, not crash.
}
await next(context);
return;
}
In Startup:
app.UseUmbraco()
.WithCustomMiddleware(u =>
{
// You control the pre pipeline calls, if you do not include this
// then packages IUmbracoPipelineFilter will not execute
u.RunPrePipeline();
// You can call the method to register the default middleware
// that Umbraco installs. You could then register custom
// middleware before or after this call.
u.UseProtectedMediaMiddleware();
u.RegisterDefaultRequiredMiddleware();
// ELSE, you control the entire pipeline, i.e.
//u.UseUmbracoCoreMiddleware();
// TODO: Add the rest ...
//u.AppBuilder.UseStatusCodePages();
//u.AppBuilder.UseImageSharp();
//u.AppBuilder.UseStaticFiles();
//u.AppBuilder.UseUmbracoPlugins();
// etc ...
// You control the post pipeline calls, if you do not include this
// then packages IUmbracoPipelineFilter will not execute
u.RunPostPipeline();
// Now include the Umbraco request middleware
u.UseBackOffice();
u.UseWebsite();
})
//.WithMiddleware(u =>
//{
// u.UseProtectedMediaMiddleware();
// u.UseBackOffice();
// u.UseWebsite();
//})
.WithEndpoints(u =>
{
u.UseInstallerEndpoints();
u.UseBackOfficeEndpoints();
u.UseWebsiteEndpoints();
});
I'm going to see if I can tidy the XPATH up a bit (it's the only way I could think of to easily get the data without going to the database - even getting all 1000 or so documents it's orders of magnitude faster than IMediaService).
Thanks for your help Huw - I hope the above is useful :)
thanks for the example code, can you advise what else needs registering in the startup.cs class / what extension method needs creating as the
u.UseProtectedMediaMiddleware();
give a build error of:
Error CS1061 'IUmbracoApplicationBuilderContext' does not contain a definition for 'UseProtectedMediaMiddleware' and no accessible extension method 'UseProtectedMediaMiddleware' accepting a first argument of type 'IUmbracoApplicationBuilderContext' could be found (are you missing a using directive or an assembly reference?)
For a middleware class (at least in .Net 6, which is where this was built) there should be no need to add the service to the services collection - just registering it with the middleware pipeline should be enough (it just needs the correct methods etc). - it's possible this has changed in Umbraco 13/.NET 8
In terms of our setup (this is no longer our project) we ended up with just this code in the Startup.cs Configure method:
That has got it running but the Middleware is not being called when requesting a URL under the media folder. I have cleared the browser cache from dev tools.
I have set breakpoints to check and the else isn't being hit when media items requested so some other middleware must be handling it before the ProtectMediaMiddleware
if (!context.Request.Path.StartsWithSegments("/media") || context.Request.IsBackOfficeRequest())
{
await next(context); // breakpoint
return;
}
else
{
int x = 1; // breakpoint
}
when loading a page such as 'About Us' at the root of the site the context.Request.Path = "/about-us/"
However when I click on the one of the links on that page that points to a .pdf document in the media tree, neither breakpoint in the if or the else are hit.
Apologies, the version of the code we ended up using actually looked like this (this thread was a while ago!):
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Web;
using HttpContext = Microsoft.AspNetCore.Http.HttpContext;
namespace Web.UI.Middleware
{
public class ProtectedMediaMiddleware
{
private readonly RequestDelegate _next;
public ProtectedMediaMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, IPublishedSnapshotService snapshotService, IUmbracoContextFactory umbracoContextFactory,
ILogger<ProtectedMediaMiddleware> logger, MediaFileManager mediaFileManager, IMemberManager memberService)
{
//If its not media, or it is media but it's an image, or it's a back office request, move on:
if (!context.Request.Path.StartsWithSegments("/media") || context.Request.Headers.ContentType.Contains("image") || context.Request.IsBackOfficeRequest() || IsImage(context.Request.Path, mediaFileManager))
{
await _next(context);
return;
}
logger.LogDebug("A file was requested from: {path}", context.Request.Path);
try
{
using var _ = umbracoContextFactory.EnsureUmbracoContext();
var snapshot = snapshotService.CreatePublishedSnapshot(null);
var mediaCache = snapshot.Media;
var mediaXpath = "//internalDocument[@isDoc]";
var allMedia = snapshot.Media.GetByXPath(mediaXpath).Cast<InternalDocument>().ToList();
//var media = snapshot.Media.GetSingleByXPath(mediaXpath);
if (allMedia.Any(x => x.Url().Contains(context.Request.Path)))
{
var media = allMedia.FirstOrDefault(x => x.Url() == context.Request.Path);
if (media != null)
{
if (media.IsDocumentType("internalDocument"))
{
//apparently the user is never actually logged in this way...
//var userName = context.User.Identity.GetUserName();
//var idToken = await context.GetTokenAsync("UmbracoMembers.OpenIdConnect", "id_token");
//var accessToken = await context.GetTokenAsync("UmbracoMembers.OpenIdConnect", "access_token");
//var userId = new Guid(userGuid);
//var currentUser = await memberService.GetCurrentMemberAsync();
var authResult = await context.AuthenticateAsync();
//memberService.find
//var isLoggedIn = await memberService.;
//memberManager.IsLoggedIn();
//context.User?.Identity?.IsAuthenticated ?? false;
if (media.Value<string>("Access").Contains("Internal") && !authResult.Succeeded)
{
//Stop processing the request and return a 401 response; user is not authorised
context.Response.StatusCode = 401;
await Task.FromResult(0);
return;
}
}
}
}
}
catch(Exception ex)
{
try
{
logger.LogError(ex, "Failed to get file {file}", context?.Request?.Path);
}
catch
{
//swallow the second exception - we want to continue, not crash.
}
await Task.FromResult(0);
return;
}
await _next(context);
return;
}
private bool IsImage(string path, MediaFileManager mediaFileManager)
{
//this should probably be our list of allowed media extensions from startup?
var imageExtensions = new List<string>() { "jpg", "jpeg", "png", "mp4", "svg", "bmp", "webp", "gif" };
//var fileStream = new FileStream(Server.MapPath(mediaPath), FileMode.Open);
var file = mediaFileManager.FileSystem.GetFullPath(path); //_mediaFileSystem.GetFullPath(mediaPath);
var extension = Path.GetExtension(file).Replace(".", String.Empty);
//var fileInfo = new FileInfo(file);
if (imageExtensions.Contains(extension))
{
return true;
}
return false;
}
}
}
So we were looking for an explicit type of media (InternalDocument) to see whether we should do the check at all, but the above approach should still work
Thanks Chris, I have amended the class so it no longer inherits from IMiddleware but get the following:
Some services are not able to be constructed (Error while validating
the service descriptor 'ServiceType:
ProjectName.ProtectedMediaMiddleware Lifetime: Scoped
ImplementationType: ProjectName.ProtectedMediaMiddleware': Unable to
resolve service for type 'Microsoft.AspNetCore.Http.RequestDelegate'
while attempting to activate 'ProjectName.ProtectedMediaMiddleware'.)
EDIT : This was fixed by removing the service.AddScoped line from startup. The project now builds ok.
Thanks Huw, I didn't need to amend my program.cs but changed how the middleware class is included in startup to match yours and this is now successfully firing the middleware.
Is this an Umbraco 13 site Nick? It's possible that things have changed significantly if so - this was implemented in Umbraco 10, which was .NET 6; it may be that there's a separate media pipeline or something like that now?
The only other modifications to startup.cs I have made are at the start of the Configure method but can't see they should change the pipeline of the requests for the media.
Custom front-end Media Controller
Hello folks,
We are in the process of upgrading a client from Umbraco 8 to 10.
As part of the Umbraco 8 site, we have a custom media controller, which checks for a specific document type (and a specific property on the document type) in order to secure media that shouldn't be available if a user is not logged on to the Intranet (so this is front-end security, not back office - any file that's not of this specific document type is returned immediately to avoid issues with media etc).
In Umbraco 8 this was fairly simple to set up - we had a component which registered the new media controller on the route, and all was fine.
However, I'm a bit flummoxed as to how to do this in Umbraco 10; I've set up a custom route for /media/index (I've followed this article: https://our.umbraco.com/documentation/Reference/Routing/Custom-Routes/ using the IVirtualPageController approach, but this seems to be more designed for returning a whole page rather than a single item; I've tried making the Controller an IComponent but that won't work because they are scoped as Singletons and we need to access scope-level services).
I'm aware of the existence of Media Protect - unfortunately that won't work for our use case, because we need to protect the media by a document type and property rather than the location of the item.
Has anybody managed to successfully implement something like this in Umbraco 9/10? Perhaps there's a better way than overriding the Media controller?
Edit: I found this post:
https://our.umbraco.com/forum/using-umbraco-and-getting-started/108979-intercept-all-media-requests
But I can't inject the services I need to check the media type and property...
Thanks,
Chris.
Hi,
Yes I do something similar, I created a middleware control which is then registered in startup. I'm not near my computer at the moment but will post my code tomorrow for you to look at.
Thank you Huw, that's very kind of you! :)
Hi Chris,
This is the code for my middleware, it isn't exactly what you want but should get you started. Basically I have a usergroup picker on medi files/folders to restrict access for certain media files. The code below checks this against the current user to see if they can access the file, if not it responds with a 401 status.
Then in your startup.cs you can add
app.ProtectedMediaHandler();
to register it.Thanks Huw,
I'd managed to get about as far as that (with the realisation I'd have to use the IMediaService rather than a cache) - my concern is with the IMediaService - I think the default implementation goes directly to the database (this is a very large site with ~100k media items and it has a public site too, which can't slow down while we do this check - the "old" way of getting media via a controller let me use the cache, which was much quicker).
That said we're only interested in doing this for non-image files, so hopefully I can filter those requests out via the path and just let the normal MiddleWare handle it :)
H5YR!
Cheers,
Chris.
if you can do it based on type/path then you won't need to get the media itself so would be much quicker. If you do need the Umbraco object for the file, you should use umbracocontext, but not sure how you would do that based only on a path (you can with the media service)
Yes, I think that's my problem - I need the object to check the properties of the MediaType so I can check a) if it's the right sort of media and b) if it's set to Internal.
I can't get that without going to the MediaService at this point in the pipeline (but I can filter out images via the path and all of these documents should be in one of a couple of folders so I could possibly check for that too, so that might be the most appropriate solution - we only want to go to the media service if we know we need to, if that makes sense.).
I've asked Umbraco Support (we're a Gold Partner) if there's some way of overriding the route as there was in Umbraco 8 - I'll post here if they come back to me, but in the meantime thanks for confirming my suspicions about how I'd have to do it :)
You may be able to check the contenttype in the request header perhaps
So I got a response from Umbraco Support and after a bit of effort I've got it mostly working (it doesn't seem to hit the code on every request at the moment), but it's...quite involved...
(We have a very simple use case for this, fortunately - there's only one member group and users can only log on if they're in that group, so if they're logged on at all they're OK to access an internal document)
In Startup:
(See https://github.com/umbraco/Umbraco-CMS/pull/10702 for details)
I'm going to see if I can tidy the XPATH up a bit (it's the only way I could think of to easily get the data without going to the database - even getting all 1000 or so documents it's orders of magnitude faster than IMediaService).
Thanks for your help Huw - I hope the above is useful :)
Hi Chris,
thanks for the example code, can you advise what else needs registering in the startup.cs class / what extension method needs creating as the
u.UseProtectedMediaMiddleware();
give a build error of:
Error CS1061 'IUmbracoApplicationBuilderContext' does not contain a definition for 'UseProtectedMediaMiddleware' and no accessible extension method 'UseProtectedMediaMiddleware' accepting a first argument of type 'IUmbracoApplicationBuilderContext' could be found (are you missing a using directive or an assembly reference?)
thanks,
Nick
Hi Nick,
Ah, that's just an extension method on the builder context, used to register the required middleware, which looks like this:
Hope that helps! :)
Chris.
That helps and has fixed that issue, thanks.
I am now getting the following:
InvalidOperationException: No service for type 'ProjectName.ProtectedMediaMiddleware' has been registered.
How should the class be added to the services collection?
Sorry for the further question but can't see any documentation at https://docs.umbraco.com/umbraco-cms/ relating to adding middleware.
Hi Nick,
For a middleware class (at least in .Net 6, which is where this was built) there should be no need to add the service to the services collection - just registering it with the middleware pipeline should be enough (it just needs the correct methods etc). - it's possible this has changed in Umbraco 13/.NET 8
In terms of our setup (this is no longer our project) we ended up with just this code in the Startup.cs Configure method:
Sorry I can't be more help on that bit - the class doesn't implement an interface and isn't registered for DI.
If it's definitely a requirement to register it, then probably the following would do it:
services.AddScoped<ProtectedMediaMiddleware, ProtectedMediaMiddleware>();
Thanks Chris, much appreciated.
That has got it running but the Middleware is not being called when requesting a URL under the media folder. I have cleared the browser cache from dev tools.
I have set breakpoints to check and the else isn't being hit when media items requested so some other middleware must be handling it before the ProtectMediaMiddleware
My startup Configure method contains:
What is context.Request.Path when it hits the if?
Hi Huw,
when loading a page such as 'About Us' at the root of the site the context.Request.Path = "/about-us/"
However when I click on the one of the links on that page that points to a .pdf document in the media tree, neither breakpoint in the if or the else are hit.
Apologies, the version of the code we ended up using actually looked like this (this thread was a while ago!):
So we were looking for an explicit type of media (InternalDocument) to see whether we should do the check at all, but the above approach should still work
Thanks Chris, I have amended the class so it no longer inherits from IMiddleware but get the following:
EDIT : This was fixed by removing the service.AddScoped line from startup. The project now builds ok.
Hi Nick,
is your middleware now working?
Hi Huw,
no, it is not being triggered when requesting items under the media folder.
It is being triggered for umbraco pages.
thanks,
Sounds like a routing issue, could you post the contents of your startup.cs file
My program.cs is as follows. Could webBuilder.UseStaticWebAssets() have an impact as it is called before UseStartup?
Mine are a little different to yours. I don't have a UseStaticWebAssets in my program.cs and my middleware class is declared differently in startup.cs
Thanks Huw, I didn't need to amend my program.cs but changed how the middleware class is included in startup to match yours and this is now successfully firing the middleware.
Thanks for your assistance :0).
your'e welcome
Thanks for the update.
Still not hitting the middleware class for items in the media folder with Chris's updated code.
Breakpoint in the middleware is hit if URL is a content page.
Is this an Umbraco 13 site Nick? It's possible that things have changed significantly if so - this was implemented in Umbraco 10, which was .NET 6; it may be that there's a separate media pipeline or something like that now?
It is an Umbraco 10 site.
The only other modifications to startup.cs I have made are at the start of the Configure method but can't see they should change the pipeline of the requests for the media.
is working on a reply...