How do I remove the charset from Content-Type in a ASP.NET Core MVC response? - asp.net

No matter what I try I cannot seem to remove the ; charset=utf-8 part from my response's Content-Type header.
[HttpGet("~/appid")]
// Doesn't work
//[Produces("application/fido.trusted-apps+json")]
public string GetAppId()
{
// Doesn't work
Response.ContentType = "application/fido.trusted-apps+json";
// Doesn't work
//Response.ContentType = null;
//Response.Headers.Add("Content-Type", "application/fido.trusted-apps+json");
return JsonConvert.SerializeObject(new
{
foo = true
});
}
I always get application/fido.trusted-apps+json; charset=utf-8 when I only want application/fido.trusted-apps+json.
Note: This is to conform with the FIDO AppID and Facet Specification v1.0 for U2F which states:
The response must set a MIME Content-Type of "application/fido.trusted-apps+json".

I went with the following approach, using middleware to replace the header on the way out. Seems kinda hacky to have to use middleware like this:
Middleware
public class AdjustHeadersMiddleware
{
private readonly RequestDelegate _next;
public AdjustHeadersMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext httpContext, CurrentContext currentContext)
{
httpContext.Response.OnStarting((state) =>
{
if(httpContext.Response.Headers.Count > 0 && httpContext.Response.Headers.ContainsKey("Content-Type"))
{
var contentType = httpContext.Response.Headers["Content-Type"].ToString();
if(contentType.StartsWith("application/fido.trusted-apps+json"))
{
httpContext.Response.Headers.Remove("Content-Type");
httpContext.Response.Headers.Append("Content-Type", "application/fido.trusted-apps+json");
}
}
return Task.FromResult(0);
}, null);
await _next.Invoke(httpContext);
}
}
Startup.cs Configure()
app.UseMiddleware<AdjustHeadersMiddleware>();

I have found that you can use ContentResult to override this in your controller. So you could achieve what you want by doing the following for example
string bodyJson = JsonConvert.SerializeObject(new
{
foo = true
})
var response = new ContentResult()
{
Content = bodyJson,
ContentType = "application/fido.trusted-apps+json",
StatusCode = (int)System.Net.HttpStatusCode.OK,
};
return response;

If the system requesting your MVC endpoint sends a proper Accept: application/fido.trusted-apps+json, then I believe a custom formatter is what you're looking for.
See:
ASP.Net Core Custom Formatters (sample code)
Write Your Own ASP.NET Core MVC Formatters
It would look something like this (borrowed from the second link):
public class FidoTrustedAppOutputFormatter : IOutputFormatter
{
public FidoTrustedAppOutputFormatter
{
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/fido.trusted-apps+json"));
}
public bool CanWriteResult(OutputFormatterCanWriteContext context)
{
if (context == null) throw new ArgumentNullException(nameof(context));
if (context.ContentType == null || context.ContentType.ToString() == "application/fido.trusted-apps+json")
return true;
return false;
}
public async Task WriteAsync(OutputFormatterWriteContext context)
{
if (context == null) throw new ArgumentNullException(nameof(context));
var response = context.HttpContext.Response; response.ContentType = "application/fido.trusted-apps+json";
using (var writer = context.WriterFactory(response.Body, Encoding.UTF8))
{
// replace with Json.net implementation
Jil.JSON.Serialize(context.Object, writer);
await writer.FlushAsync();
}
}
}
public class FidoTrustedAppInputFormatter : IInputFormatter
{
public FidoTrustedAppInputFormatter
{
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/fido.trusted-apps+json"));
}
public bool CanRead(OutputFormatterCanWriteContext context)
{
if (context == null) throw new ArgumentNullException(nameof(context));
if (context.ContentType == null || context.ContentType.ToString() == "application/fido.trusted-apps+json")
return true;
return false;
}
public Task<InputFormatterResult> ReadAsync(InputFormatterContext context)
{
if (context == null) throw new ArgumentNullException(nameof(context));
var request = context.HttpContext.Request; if (request.ContentLength == 0)
{
if (context.ModelType.GetTypeInfo().IsValueType)
return InputFormatterResult.SuccessAsync(Activator.CreateInstance(context.ModelType));
else return InputFormatterResult.SuccessAsync(null);
}
var encoding = Encoding.UTF8;//do we need to get this from the request im not sure yet
using (var reader = new StreamReader(context.HttpContext.Request.Body))
{
var model = Jil.JSON.Deserialize(reader, context.ModelType);
return InputFormatterResult.SuccessAsync(model);
}
}
}
Then register it in your startup:
services.AddMvcCore(options =>
{
options.InputFormatters.Insert(0, new FidoTrustedAppInputFormatter ());
options.OutputFormatters.Insert(0, new FidoTrustedAppOutputFormatter ());
});

Related

EnableRewind and leaveOpen on StreamReader are not stopping the request from being disposed

I'm using ApplicationInsights and I want to add the request, and after that the response, to the logging properties.
To achieve this I am implementing my own ITelemetryInitializer. It looks exactly like this.
public class MyInitializer : ITelemetryInitializer
{
private readonly IHttpContextAccessor _httpContextAccessor;
public MyInitializer(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public void Initialize(ITelemetry telemetry)
{
var requestTelemetry = telemetry as RequestTelemetry;
if (requestTelemetry == null || _httpContextAccessor?.HttpContext?.Request == null
|| requestTelemetry.Properties.ContainsKey("RequestBody"))
{
return;
}
var request = _httpContextAccessor?.HttpContext?.Request;
request?.EnableRewind();
if (request.Method.Equals(HttpMethod.Post.ToString(), StringComparison.InvariantCultureIgnoreCase)
|| request.Method.Equals(HttpMethod.Put.ToString(), StringComparison.InvariantCultureIgnoreCase))
{
using (var reader = new StreamReader(request.Body, Encoding.UTF8, true, 1024, true))
{
var requestBody = reader.ReadToEnd();
requestTelemetry.Properties.Add("RequestBody", requestBody);
}
}
}
}
In startup I've added this
services.AddHttpContextAccessor();
services.AddSingleton<ITelemetryInitializer, MyInitializer>();
services.AddApplicationInsightsTelemetry();
The error I get is:
ObjectDisposedException: Cannot access a disposed object.
Object name: FileBufferingReadStream.
Microsoft.AspNetCore.WebUtilities.FileBufferingReadStream.ThrowIfDisposed()
I've used .EnableRewind as well as instructing the StreamReader to leave the file open. Despite this my request is still null when it actually hits my controller, or even when it hits my initializer again for a second pass (setting the response information).
Any suggestions are welcome.
Additionally I tried adding a piece of middleware to ensure .EnableRewind was on for everything, but this did nothing. I'd prefer not to have to add any additional middleware since I'd like there to be no other dependencies.
app.Use(async (context, next) =>
{
context.Request.EnableRewind();
await next();
});
Thanks.
As always the solution ends up being a single line of code. I owe Mr Gunnar Peipman a thanks for his blog post Reading request body in ASP.NET Core.
The line:
request.Body.Seek(0, SeekOrigin.Begin);
The code
public class MyInitializer : ITelemetryInitializer
{
private readonly IHttpContextAccessor _httpContextAccessor;
public MyInitializer(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public void Initialize(ITelemetry telemetry)
{
var requestTelemetry = telemetry as RequestTelemetry;
if (requestTelemetry == null || _httpContextAccessor?.HttpContext?.Request == null
|| requestTelemetry.Properties.ContainsKey("RequestBody"))
{
return;
}
var request = _httpContextAccessor?.HttpContext?.Request;
request?.EnableRewind();
if (request.Method.Equals(HttpMethod.Post.ToString(), StringComparison.InvariantCultureIgnoreCase)
|| request.Method.Equals(HttpMethod.Put.ToString(), StringComparison.InvariantCultureIgnoreCase))
{
using (var reader = new StreamReader(request.Body, Encoding.UTF8, true, 1024, true))
{
var requestBody = reader.ReadToEnd();
request.Body.Seek(0, SeekOrigin.Begin);
requestTelemetry.Properties.Add("RequestBody", requestBody);
}
}
}
}

Read Asp.Net Core Response body in ActionFilterAttribute

I'm using Asp.Net Core as a Rest Api Service.
I need access to request and response in ActionFilter. Actually, I found the request in OnActionExcecuted but I can't read the response result.
I'm trying to return value as follow:
[HttpGet]
[ProducesResponseType(typeof(ResponseType), (int)HttpStatusCode.OK)]
[Route("[action]")]
public async Task<IActionResult> Get(CancellationToken cancellationToken)
{
var model = await _responseServices.Get(cancellationToken);
return Ok(model);
}
And in ActionFilter OnExcecuted method as follow:
_request = context.HttpContext.Request.ReadAsString().Result;
_response = context.HttpContext.Response.ReadAsString().Result; //?
I'm trying to get the response in ReadAsString as an Extension method as follow:
public static async Task<string> ReadAsString(this HttpResponse response)
{
var initialBody = response.Body;
var buffer = new byte[Convert.ToInt32(response.ContentLength)];
await response.Body.ReadAsync(buffer, 0, buffer.Length);
var body = Encoding.UTF8.GetString(buffer);
response.Body = initialBody;
return body;
}
But, there is no result!
How I can get the response in OnActionExcecuted?
Thanks, everyone for taking the time to try and help explain
If you're logging for json result/ view result , you don't need to read the whole response stream. Simply serialize the context.Result:
public class MyFilterAttribute : ActionFilterAttribute
{
private ILogger<MyFilterAttribute> logger;
public MyFilterAttribute(ILogger<MyFilterAttribute> logger){
this.logger = logger;
}
public override void OnActionExecuted(ActionExecutedContext context)
{
var result = context.Result;
if (result is JsonResult json)
{
var x = json.Value;
var status = json.StatusCode;
this.logger.LogInformation(JsonConvert.SerializeObject(x));
}
if(result is ViewResult view){
// I think it's better to log ViewData instead of the finally rendered template string
var status = view.StatusCode;
var x = view.ViewData;
var name = view.ViewName;
this.logger.LogInformation(JsonConvert.SerializeObject(x));
}
else{
this.logger.LogInformation("...");
}
}
I know there is already an answer but I want to also add that the problem is the MVC pipeline has not populated the Response.Body when running an ActionFilter so you cannot access it. The Response.Body is populated by the MVC middleware.
If you want to read Response.Body then you need to create your own custom middleware to intercept the call when the Response object has been populated. There are numerous websites that can show you how to do this. One example is here.
As discussed in the other answer, if you want to do it in an ActionFilter you can use the context.Result to access the information.
For logging whole request and response in the ASP.NET Core filter pipeline you can use Result filter attribute
public class LogRequestResponseAttribute : TypeFilterAttribute
{
public LogRequestResponseAttribute() : base(typeof(LogRequestResponseImplementation)) { }
private class LogRequestResponseImplementation : IAsyncResultFilter
{
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
var requestHeadersText = CommonLoggingTools.SerializeHeaders(context.HttpContext.Request.Headers);
Log.Information("requestHeaders: " + requestHeadersText);
var requestBodyText = await CommonLoggingTools.FormatRequestBody(context.HttpContext.Request);
Log.Information("requestBody: " + requestBodyText);
await next();
var responseHeadersText = CommonLoggingTools.SerializeHeaders(context.HttpContext.Response.Headers);
Log.Information("responseHeaders: " + responseHeadersText);
var responseBodyText = await CommonLoggingTools.FormatResponseBody(context.HttpContext.Response);
Log.Information("responseBody: " + responseBodyText);
}
}
}
In Startup.cs add
app.UseMiddleware<ResponseRewindMiddleware>();
services.AddScoped<LogRequestResponseAttribute>();
Somewhere add static class
public static class CommonLoggingTools
{
public static async Task<string> FormatRequestBody(HttpRequest request)
{
//This line allows us to set the reader for the request back at the beginning of its stream.
request.EnableRewind();
//We now need to read the request stream. First, we create a new byte[] with the same length as the request stream...
var buffer = new byte[Convert.ToInt32(request.ContentLength)];
//...Then we copy the entire request stream into the new buffer.
await request.Body.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
//We convert the byte[] into a string using UTF8 encoding...
var bodyAsText = Encoding.UTF8.GetString(buffer);
//..and finally, assign the read body back to the request body, which is allowed because of EnableRewind()
request.Body.Position = 0;
return $"{request.Scheme} {request.Host}{request.Path} {request.QueryString} {bodyAsText}";
}
public static async Task<string> FormatResponseBody(HttpResponse response)
{
//We need to read the response stream from the beginning...
response.Body.Seek(0, SeekOrigin.Begin);
//...and copy it into a string
string text = await new StreamReader(response.Body).ReadToEndAsync();
//We need to reset the reader for the response so that the client can read it.
response.Body.Seek(0, SeekOrigin.Begin);
response.Body.Position = 0;
//Return the string for the response, including the status code (e.g. 200, 404, 401, etc.)
return $"{response.StatusCode}: {text}";
}
public static string SerializeHeaders(IHeaderDictionary headers)
{
var dict = new Dictionary<string, string>();
foreach (var item in headers.ToList())
{
//if (item.Value != null)
//{
var header = string.Empty;
foreach (var value in item.Value)
{
header += value + " ";
}
// Trim the trailing space and add item to the dictionary
header = header.TrimEnd(" ".ToCharArray());
dict.Add(item.Key, header);
//}
}
return JsonConvert.SerializeObject(dict, Formatting.Indented);
}
}
public class ResponseRewindMiddleware {
private readonly RequestDelegate next;
public ResponseRewindMiddleware(RequestDelegate next) {
this.next = next;
}
public async Task Invoke(HttpContext context) {
Stream originalBody = context.Response.Body;
try {
using (var memStream = new MemoryStream()) {
context.Response.Body = memStream;
await next(context);
//memStream.Position = 0;
//string responseBody = new StreamReader(memStream).ReadToEnd();
memStream.Position = 0;
await memStream.CopyToAsync(originalBody);
}
} finally {
context.Response.Body = originalBody;
}
}
You can also do...
string response = "Hello";
if (result is ObjectResult objectResult)
{
var status = objectResult.StatusCode;
var value = objectResult.Value;
var stringResult = objectResult.ToString();
responce = (JsonConvert.SerializeObject(value));
}
I used this in a .net core app.
Hope it helps.

Adding a SessionStore (ITicketStore) to my application cookie makes my Data Protection Provider fail to work

tl;dr
Have .NET Core 2.0 application which uses a Data Protection Provider which persists a key file across all of the sites on my domain.
Worked fine, however, application cookie became too big.
Implemented a SessionStore on the cookie using ITicketStore
Cookie size is greatly reduced, however, the key from the DPP no longer persists across my sites.
Is there something I'm supposed to do in my ITicketStore implementation to fix this? I'm assuming so, since this is where the problem arises, however, I could not figure it out.
Some snippets:
Startup.cs --> ConfigureServices()
var keysFolder = $#"c:\temp\_WebAppKeys\{_env.EnvironmentName.ToLower()}";
var protectionProvider = DataProtectionProvider.Create(new DirectoryInfo(keysFolder));
var dataProtector = protectionProvider.CreateProtector(
"Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationMiddleware",
"Cookies",
"v2");
--snip--
services.AddSingleton<ITicketStore, TicketStore>();
--snip--
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keysFolder))
.SetApplicationName("app_auth");
services.ConfigureApplicationCookie(options =>
{
options.Cookie.Name = ".XAUTH";
options.Cookie.Domain = ".domain.com";
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.LoginPath = "/Account/Login";
options.DataProtectionProvider = protectionProvider;
options.TicketDataFormat = new TicketDataFormat(dataProtector);
options.CookieManager = new ChunkingCookieManager();
options.SessionStore = services.BuildServiceProvider().GetService<ITicketStore>();
});
TicketStore.cs
public class TicketStore : ITicketStore
{
private IMemoryCache _cache;
private const string KeyPrefix = "AuthSessionStore-";
public TicketStore(IMemoryCache cache)
{
_cache = cache;
}
public Task RemoveAsync(string key)
{
_cache.Remove(key);
return Task.FromResult(0);
}
public Task RenewAsync(string key, AuthenticationTicket ticket)
{
var options = new MemoryCacheEntryOptions
{
Priority = CacheItemPriority.NeverRemove
};
var expiresUtc = ticket.Properties.ExpiresUtc;
if (expiresUtc.HasValue)
{
options.SetAbsoluteExpiration(expiresUtc.Value);
}
options.SetSlidingExpiration(TimeSpan.FromMinutes(60));
_cache.Set(key, ticket, options);
return Task.FromResult(0);
}
public Task<AuthenticationTicket> RetrieveAsync(string key)
{
AuthenticationTicket ticket;
_cache.TryGetValue(key, out ticket);
return Task.FromResult(ticket);
}
public async Task<string> StoreAsync(AuthenticationTicket ticket)
{
var key = KeyPrefix + Guid.NewGuid();
await RenewAsync(key, ticket);
return key;
}
I also ran into this issue.
The SessionIdClaim value in Microsoft.Owin.Security.Cookies is "Microsoft.Owin.Security.Cookies-SessionId", while the SessionIdClaim value in Microsoft.AspNetCore.Authentication.Cookies is "Microsoft.AspNetCore.Authentication.Cookies-SessionId".
This results in a SessionId Missing error due to this code on the AspNetCore side even when you implemented a distributed session store (using RedisCacheTicketStore for example) as decribed here: https://mikerussellnz.github.io/.NET-Core-Auth-Ticket-Redis/
I was able to re-compile the AspNetKatana project with the new string, and then the SessionID was found on the .NET Core side.
Additionally, it seems the AuthenticationTicket classes are different, so I was able to get this working by implementing a conversion method to convert the
Microsoft.Owin.Security.AuthenticationTicket Ticket to the Microsoft.AspNetCore.Authentication.AuthenticationTicket Ticket and then store the ticket using the AspNetCore serializer (Microsoft.AspNetCore.Authentication.TicketSerializer).
public Microsoft.AspNetCore.Authentication.AuthenticationTicket ConvertTicket(Microsoft.Owin.Security.AuthenticationTicket ticket)
{
Microsoft.AspNetCore.Authentication.AuthenticationProperties netCoreAuthProps = new Microsoft.AspNetCore.Authentication.AuthenticationProperties();
netCoreAuthProps.IssuedUtc = ticket.Properties.IssuedUtc;
netCoreAuthProps.ExpiresUtc = ticket.Properties.ExpiresUtc;
netCoreAuthProps.IsPersistent = ticket.Properties.IsPersistent;
netCoreAuthProps.AllowRefresh = ticket.Properties.AllowRefresh;
netCoreAuthProps.RedirectUri = ticket.Properties.RedirectUri;
ClaimsPrincipal cp = new ClaimsPrincipal(ticket.Identity);
Microsoft.AspNetCore.Authentication.AuthenticationTicket netCoreTicket = new Microsoft.AspNetCore.Authentication.AuthenticationTicket(cp, netCoreAuthProps, "Cookies");
return netCoreTicket;
}
private static Microsoft.AspNetCore.Authentication.TicketSerializer _netCoreSerializer = Microsoft.AspNetCore.Authentication.TicketSerializer.Default;
private static byte[] SerializeToBytesNetCore(Microsoft.AspNetCore.Authentication.AuthenticationTicket source)
{
return _netCoreSerializer.Serialize(source);
}
With these additional methods, the RenwAsync method can be changed to this:
public Task RenewAsync(string key, Microsoft.Owin.Security.AuthenticationTicket ticket)
{
var options = new DistributedCacheEntryOptions();
var expiresUtc = ticket.Properties.ExpiresUtc;
if (expiresUtc.HasValue)
{
options.SetAbsoluteExpiration(expiresUtc.Value);
}
var netCoreTicket = ConvertTicket(ticket);
// convert to .NET Core format
byte[] netCoreVal = SerializeToBytesNetCore(netCoreTicket);
// serialize ticket using .NET Core Serializer
_cache.Set(key, netCoreVal, options);
return Task.FromResult(0);
}
I am not sure if this is the best approach, but it seems to work on my test project, admittedly I am not using this in production, hopefully this helps.
UPDATE #1: Alternate approach to avoid re-compiling
It looks like this might also work by re-creating the cookie with both SessionId claim values on the OWIN side. This will allow you to use the standard library without re-compiling. I tried it this morning but have not had a chance to thoroughly test it, although on my initial test it does load the claims properly on both sides. Basically, if you modify the authentication ticket to have both SessionId claims, it will find the session in both applications. This code snippet gets the cookie, unprotects it, adds the additional claim, and then replaces the cookie inside the OnValidateIdentity event of the CookieAuthenticationProvider.
string cookieName = "myappname";
string KatanaSessionIdClaim = "Microsoft.Owin.Security.Cookies-SessionId";
string NetCoreSessionIdClaim = "Microsoft.AspNetCore.Authentication.Cookies-SessionId";
Microsoft.Owin.Security.Interop.ChunkingCookieManager cookieMgr = new ChunkingCookieManager();
OnValidateIdentity = ctx =>
{
var incomingIdentity = ctx.Identity;
var cookie = cookieMgr.GetRequestCookie(ctx.OwinContext, cookieName);
if (cookie != null)
{
var ticket = TicketDataFormat.Unprotect(cookie);
if (ticket != null)
{
Claim claim = ticket.Identity.Claims.FirstOrDefault(c => c.Type.Equals(KatanaSessionIdClaim));
Claim netCoreSessionClaim = ticket.Identity.Claims.FirstOrDefault(c => c.Type.Equals(NetCoreSessionIdClaim));
if (netCoreSessionClaim == null)
{
// adjust cookie options as needed.
CookieOptions opts = new CookieOptions();
opts.Expires = ticket.Properties.ExpiresUtc == null ?
DateTime.Now.AddDays(14) : ticket.Properties.ExpiresUtc.Value.DateTime;
opts.HttpOnly = true;
opts.Path = "/";
opts.Secure = true;
netCoreSessionClaim = new Claim(NetCoreSessionIdClaim, claim.Value);
ticket.Identity.AddClaim(netCoreSessionClaim);
string newCookieValue = TicketDataFormat.Protect(ticket);
cookieMgr.DeleteCookie(ctx.OwinContext, cookieName, opts);
cookieMgr.AppendResponseCookie(ctx.OwinContext, cookieName, newCookieValue, opts);
}
}
}
}
If there is a better approach I would be curious to know, or a better place to swap out the cookie.
The problem is, as other answers have pointed out, that the Owin cookie's session key claim has another type string than the one expected in ASP.Net Core.
The following implementation of a ticket data format makes sure to add the session key claim for ASP.Net Core when generating the cookie string.
public class AspNetCoreCompatibleTicketDataFormat : ISecureDataFormat<AuthenticationTicket> {
private const string OwinSessionIdClaim = "Microsoft.Owin.Security.Cookies-SessionId";
private const string AspNetCoreSessionIdClaim = "Microsoft.AspNetCore.Authentication.Cookies-SessionId";
private readonly ISecureDataFormat<AuthenticationTicket> dataFormat;
public AspNetCoreCompatibleTicketDataFormat(IDataProtector protector) {
this.dataFormat = new AspNetTicketDataFormat(protector);
}
public string Protect(AuthenticationTicket data) {
var sessionClaim = data.Identity.FindFirst(OwinSessionIdClaim);
if (sessionClaim != null) {
data.Identity.AddClaim(new Claim(AspNetCoreSessionIdClaim, sessionClaim.Value));
}
return this.dataFormat.Protect(data);
}
public AuthenticationTicket Unprotect(string protectedText) {
return this.dataFormat.Unprotect(protectedText);
}
}
This code should be added to the ASP.Net Framework project. You use it instead of the AspNetTicketDataFormat in your StartUp.cs code, like this:
app.UseCookieAuthentication(new CookieAuthenticationOptions {
TicketDataFormat = new AspNetCoreCompatibleTicketDataFormat(
new DataProtectorShim(...
The code makes sure that the generated cookie contains a session id claim known to ASP.NET Core. It works for the scenario where you generate the cookie in an ASP.NET Framework OWIN project and consume it in an ASP.NET Core project.
One would have to make sure to always add both to get it working in the opposite circumstance where the cookie is generated in the ASP.NET Core project.
I ended up doing a mix of the above answers, Replacing the ICookieManager implementation on the AspNetCore side that generates the cookies, adding both claims when doing so (as per the relevant part of the answer given by #AnthonyValeri):
public class OwinAspNetCompatibleCookieManager : ICookieManager
{
private const string OwinSessionIdClaim = "Microsoft.Owin.Security.Cookies-SessionId";
private const string AspNetCoreSessionIdClaim = "Microsoft.AspNetCore.Authentication.Cookies-SessionId";
private readonly ICookieManager actualCookieManager;
public OwinAspNetCompatibleCookieManager(ICookieManager actualCookieManager) => this.actualCookieManager = actualCookieManager;
// TODO oh this async void is so so bad, i have to find another way
public async void AppendResponseCookie(HttpContext context, string key, string value, CookieOptions options)
{
IAuthenticationHandler handler = await context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>().GetHandlerAsync(context, CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false);
if (handler is CookieAuthenticationHandler cookieHandler)
{
value = MakeOwinAspNetCoreCompatible(key, value, cookieHandler.Options);
}
actualCookieManager.AppendResponseCookie(context, key, value, options);
}
public void DeleteCookie(HttpContext context, string key, CookieOptions options)
{
actualCookieManager.DeleteCookie(context, key, options);
}
public string GetRequestCookie(HttpContext context, string key)
{
return actualCookieManager.GetRequestCookie(context, key);
}
private string MakeOwinAspNetCoreCompatible(string key, string cookieValue, CookieAuthenticationOptions options)
{
if (key.Equals("MySharedCookieName") && !string.IsNullOrWhiteSpace(cookieValue))
{
AuthenticationTicket ticket = options.TicketDataFormat.Unprotect(cookieValue);
ClaimsPrincipal principal = ticket.Principal;
Claim aspNetCoreClaim = ticket.Principal.Claims.FirstOrDefault(x => x.Type.Equals(AspNetCoreSessionIdClaim));
Claim owinClaim = ticket.Principal.Claims.FirstOrDefault(x => x.Type.Equals(OwinSessionIdClaim));
Claim[] claims = null;
if (aspNetCoreClaim != null && owinClaim == null)
{
claims = new Claim[] { aspNetCoreClaim, new Claim(OwinSessionIdClaim, aspNetCoreClaim.Value) };
}
else if (aspNetCoreClaim == null && owinClaim != null)
{
claims = new Claim[] { owinClaim, new Claim(AspNetCoreSessionIdClaim, owinClaim.Value) };
}
if (claims?.Length > 0)
{
var newIdentity = new ClaimsIdentity(claims, principal.Identity.AuthenticationType);
principal = new ClaimsPrincipal(newIdentity);
ticket = new AuthenticationTicket(principal, ticket.AuthenticationScheme);
cookieValue = options.TicketDataFormat.Protect(ticket);
}
}
return cookieValue;
}
}
And then configuring it on the .AddCookie() call in ConfigureServices:
...
options.CookieManager = new OwinAspNetCompatibleCookieManager(new ChunkingCookieManager());
...
I ran into the same issue and banging my head to resolve this. But thanks to #Anthony Valeri to pointing right at where the issue is. So I came up with the solution below. (I was doing this as part of POC for one of our migration projects and this is not been tested in Production, but worked for POC.)
Created an extended CookieAuthenticationOptions class and added a new property.
public class ExtendedCookieAuthenticationOptions : CookieAuthenticationOptions
{
public string SessionIdClaim { get; set; }
}
Copied CookieAuthenticationHandler class from GitHub Source Code and extended that with above class
public class ExtendedCookieAuthenticationHandler : SignInAuthenticationHandler<ExtendedCookieAuthenticationOptions>
{
private const string HeaderValueNoCache = "no-cache";
private const string HeaderValueEpocDate = "Thu, 01 Jan 1970 00:00:00 GMT";
private const string SessionIdClaim = "Microsoft.AspNetCore.Authentication.Cookies-SessionId";
private bool _shouldRefresh;
private bool _signInCalled;
private bool _signOutCalled;
private DateTimeOffset? _refreshIssuedUtc;
private DateTimeOffset? _refreshExpiresUtc;
private string _sessionKey;
private Task<AuthenticateResult> _readCookieTask;
private AuthenticationTicket _refreshTicket;
public ExtendedCookieAuthenticationHandler(IOptionsMonitor<ExtendedCookieAuthenticationOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
/// <summary>
/// Added this to overwrite default SessionIdClaim value
/// </summary>
public virtual string SessionIdClaimType
{
get { return string.IsNullOrEmpty(Options.SessionIdClaim) ? SessionIdClaim : Options.SessionIdClaim; }
}
/// <summary>
/// The handler calls methods on the events which give the application control at certain points where processing is occurring.
/// If it is not provided a default instance is supplied which does nothing when the methods are called.
/// </summary>
protected new CookieAuthenticationEvents Events
{
get { return (CookieAuthenticationEvents)base.Events; }
set { base.Events = value; }
}
protected override Task InitializeHandlerAsync()
{
// Cookies needs to finish the response
Context.Response.OnStarting(FinishResponseAsync);
return Task.CompletedTask;
}
/// <summary>
/// Creates a new instance of the events instance.
/// </summary>
/// <returns>A new instance of the events instance.</returns>
protected override Task<object> CreateEventsAsync() => Task.FromResult<object>(new CookieAuthenticationEvents());
private Task<AuthenticateResult> EnsureCookieTicket()
{
// We only need to read the ticket once
if (_readCookieTask == null)
{
_readCookieTask = ReadCookieTicket();
}
return _readCookieTask;
}
private void CheckForRefresh(AuthenticationTicket ticket)
{
var currentUtc = Clock.UtcNow;
var issuedUtc = ticket.Properties.IssuedUtc;
var expiresUtc = ticket.Properties.ExpiresUtc;
var allowRefresh = ticket.Properties.AllowRefresh ?? true;
if (issuedUtc != null && expiresUtc != null && Options.SlidingExpiration && allowRefresh)
{
var timeElapsed = currentUtc.Subtract(issuedUtc.Value);
var timeRemaining = expiresUtc.Value.Subtract(currentUtc);
if (timeRemaining < timeElapsed)
{
RequestRefresh(ticket);
}
}
}
private void RequestRefresh(AuthenticationTicket ticket, ClaimsPrincipal replacedPrincipal = null)
{
var issuedUtc = ticket.Properties.IssuedUtc;
var expiresUtc = ticket.Properties.ExpiresUtc;
if (issuedUtc != null && expiresUtc != null)
{
_shouldRefresh = true;
var currentUtc = Clock.UtcNow;
_refreshIssuedUtc = currentUtc;
var timeSpan = expiresUtc.Value.Subtract(issuedUtc.Value);
_refreshExpiresUtc = currentUtc.Add(timeSpan);
_refreshTicket = CloneTicket(ticket, replacedPrincipal);
}
}
private AuthenticationTicket CloneTicket(AuthenticationTicket ticket, ClaimsPrincipal replacedPrincipal)
{
var principal = replacedPrincipal ?? ticket.Principal;
var newPrincipal = new ClaimsPrincipal();
foreach (var identity in principal.Identities)
{
newPrincipal.AddIdentity(identity.Clone());
}
var newProperties = new AuthenticationProperties();
foreach (var item in ticket.Properties.Items)
{
newProperties.Items[item.Key] = item.Value;
}
return new AuthenticationTicket(newPrincipal, newProperties, ticket.AuthenticationScheme);
}
private async Task<AuthenticateResult> ReadCookieTicket()
{
var cookie = Options.CookieManager.GetRequestCookie(Context, Options.Cookie.Name);
if (string.IsNullOrEmpty(cookie))
{
return AuthenticateResult.NoResult();
}
var ticket = Options.TicketDataFormat.Unprotect(cookie, GetTlsTokenBinding());
if (ticket == null)
{
return AuthenticateResult.Fail("Unprotect ticket failed");
}
if (Options.SessionStore != null)
{
var claim = ticket.Principal.Claims.FirstOrDefault(c => c.Type.Equals(SessionIdClaimType));
if (claim == null)
{
return AuthenticateResult.Fail("SessionId missing");
}
_sessionKey = claim.Value;
ticket = await Options.SessionStore.RetrieveAsync(_sessionKey);
if (ticket == null)
{
return AuthenticateResult.Fail("Identity missing in session store");
}
}
var currentUtc = Clock.UtcNow;
var expiresUtc = ticket.Properties.ExpiresUtc;
if (expiresUtc != null && expiresUtc.Value < currentUtc)
{
if (Options.SessionStore != null)
{
await Options.SessionStore.RemoveAsync(_sessionKey);
}
return AuthenticateResult.Fail("Ticket expired");
}
CheckForRefresh(ticket);
// Finally we have a valid ticket
return AuthenticateResult.Success(ticket);
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var result = await EnsureCookieTicket();
if (!result.Succeeded)
{
return result;
}
var context = new CookieValidatePrincipalContext(Context, Scheme, Options, result.Ticket);
await Events.ValidatePrincipal(context);
if (context.Principal == null)
{
return AuthenticateResult.Fail("No principal.");
}
if (context.ShouldRenew)
{
RequestRefresh(result.Ticket, context.Principal);
}
return AuthenticateResult.Success(new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name));
}
private CookieOptions BuildCookieOptions()
{
var cookieOptions = Options.Cookie.Build(Context);
// ignore the 'Expires' value as this will be computed elsewhere
cookieOptions.Expires = null;
return cookieOptions;
}
protected virtual async Task FinishResponseAsync()
{
// Only renew if requested, and neither sign in or sign out was called
if (!_shouldRefresh || _signInCalled || _signOutCalled)
{
return;
}
var ticket = _refreshTicket;
if (ticket != null)
{
var properties = ticket.Properties;
if (_refreshIssuedUtc.HasValue)
{
properties.IssuedUtc = _refreshIssuedUtc;
}
if (_refreshExpiresUtc.HasValue)
{
properties.ExpiresUtc = _refreshExpiresUtc;
}
if (Options.SessionStore != null && _sessionKey != null)
{
await Options.SessionStore.RenewAsync(_sessionKey, ticket);
var principal = new ClaimsPrincipal(
new ClaimsIdentity(
new[] { new Claim(SessionIdClaimType, _sessionKey, ClaimValueTypes.String, Options.ClaimsIssuer) },
Scheme.Name));
ticket = new AuthenticationTicket(principal, null, Scheme.Name);
}
var cookieValue = Options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding());
var cookieOptions = BuildCookieOptions();
if (properties.IsPersistent && _refreshExpiresUtc.HasValue)
{
cookieOptions.Expires = _refreshExpiresUtc.Value.ToUniversalTime();
}
Options.CookieManager.AppendResponseCookie(
Context,
Options.Cookie.Name,
cookieValue,
cookieOptions);
await ApplyHeaders(shouldRedirectToReturnUrl: false, properties: properties);
}
}
protected async override Task HandleSignInAsync(ClaimsPrincipal user, AuthenticationProperties properties)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
properties = properties ?? new AuthenticationProperties();
_signInCalled = true;
// Process the request cookie to initialize members like _sessionKey.
await EnsureCookieTicket();
var cookieOptions = BuildCookieOptions();
var signInContext = new CookieSigningInContext(
Context,
Scheme,
Options,
user,
properties,
cookieOptions);
DateTimeOffset issuedUtc;
if (signInContext.Properties.IssuedUtc.HasValue)
{
issuedUtc = signInContext.Properties.IssuedUtc.Value;
}
else
{
issuedUtc = Clock.UtcNow;
signInContext.Properties.IssuedUtc = issuedUtc;
}
if (!signInContext.Properties.ExpiresUtc.HasValue)
{
signInContext.Properties.ExpiresUtc = issuedUtc.Add(Options.ExpireTimeSpan);
}
await Events.SigningIn(signInContext);
if (signInContext.Properties.IsPersistent)
{
var expiresUtc = signInContext.Properties.ExpiresUtc ?? issuedUtc.Add(Options.ExpireTimeSpan);
signInContext.CookieOptions.Expires = expiresUtc.ToUniversalTime();
}
var ticket = new AuthenticationTicket(signInContext.Principal, signInContext.Properties, signInContext.Scheme.Name);
if (Options.SessionStore != null)
{
if (_sessionKey != null)
{
await Options.SessionStore.RemoveAsync(_sessionKey);
}
_sessionKey = await Options.SessionStore.StoreAsync(ticket);
var principal = new ClaimsPrincipal(
new ClaimsIdentity(
new[] { new Claim(SessionIdClaimType, _sessionKey, ClaimValueTypes.String, Options.ClaimsIssuer) },
Options.ClaimsIssuer));
ticket = new AuthenticationTicket(principal, null, Scheme.Name);
}
var cookieValue = Options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding());
Options.CookieManager.AppendResponseCookie(
Context,
Options.Cookie.Name,
cookieValue,
signInContext.CookieOptions);
var signedInContext = new CookieSignedInContext(
Context,
Scheme,
signInContext.Principal,
signInContext.Properties,
Options);
await Events.SignedIn(signedInContext);
// Only redirect on the login path
var shouldRedirect = Options.LoginPath.HasValue && OriginalPath == Options.LoginPath;
await ApplyHeaders(shouldRedirect, signedInContext.Properties);
Logger.AuthenticationSchemeSignedIn(Scheme.Name);
}
protected async override Task HandleSignOutAsync(AuthenticationProperties properties)
{
properties = properties ?? new AuthenticationProperties();
_signOutCalled = true;
// Process the request cookie to initialize members like _sessionKey.
await EnsureCookieTicket();
var cookieOptions = BuildCookieOptions();
if (Options.SessionStore != null && _sessionKey != null)
{
await Options.SessionStore.RemoveAsync(_sessionKey);
}
var context = new CookieSigningOutContext(
Context,
Scheme,
Options,
properties,
cookieOptions);
await Events.SigningOut(context);
Options.CookieManager.DeleteCookie(
Context,
Options.Cookie.Name,
context.CookieOptions);
// Only redirect on the logout path
var shouldRedirect = Options.LogoutPath.HasValue && OriginalPath == Options.LogoutPath;
await ApplyHeaders(shouldRedirect, context.Properties);
Logger.AuthenticationSchemeSignedOut(Scheme.Name);
}
private async Task ApplyHeaders(bool shouldRedirectToReturnUrl, AuthenticationProperties properties)
{
Response.Headers[HeaderNames.CacheControl] = HeaderValueNoCache;
Response.Headers[HeaderNames.Pragma] = HeaderValueNoCache;
Response.Headers[HeaderNames.Expires] = HeaderValueEpocDate;
if (shouldRedirectToReturnUrl && Response.StatusCode == 200)
{
// set redirect uri in order:
// 1. properties.RedirectUri
// 2. query parameter ReturnUrlParameter
//
// Absolute uri is not allowed if it is from query string as query string is not
// a trusted source.
var redirectUri = properties.RedirectUri;
if (string.IsNullOrEmpty(redirectUri))
{
redirectUri = Request.Query[Options.ReturnUrlParameter];
if (string.IsNullOrEmpty(redirectUri) || !IsHostRelative(redirectUri))
{
redirectUri = null;
}
}
if (redirectUri != null)
{
await Events.RedirectToReturnUrl(
new RedirectContext<CookieAuthenticationOptions>(Context, Scheme, Options, properties, redirectUri));
}
}
}
private static bool IsHostRelative(string path)
{
if (string.IsNullOrEmpty(path))
{
return false;
}
if (path.Length == 1)
{
return path[0] == '/';
}
return path[0] == '/' && path[1] != '/' && path[1] != '\\';
}
protected override async Task HandleForbiddenAsync(AuthenticationProperties properties)
{
var returnUrl = properties.RedirectUri;
if (string.IsNullOrEmpty(returnUrl))
{
returnUrl = OriginalPathBase + OriginalPath + Request.QueryString;
}
var accessDeniedUri = Options.AccessDeniedPath + QueryString.Create(Options.ReturnUrlParameter, returnUrl);
var redirectContext = new RedirectContext<CookieAuthenticationOptions>(Context, Scheme, Options, properties, BuildRedirectUri(accessDeniedUri));
await Events.RedirectToAccessDenied(redirectContext);
}
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
var redirectUri = properties.RedirectUri;
if (string.IsNullOrEmpty(redirectUri))
{
redirectUri = OriginalPathBase + OriginalPath + Request.QueryString;
}
var loginUri = Options.LoginPath + QueryString.Create(Options.ReturnUrlParameter, redirectUri);
var redirectContext = new RedirectContext<CookieAuthenticationOptions>(Context, Scheme, Options, properties, BuildRedirectUri(loginUri));
await Events.RedirectToLogin(redirectContext);
}
private string GetTlsTokenBinding()
{
var binding = Context.Features.Get<ITlsTokenBindingFeature>()?.GetProvidedTokenBindingId();
return binding == null ? null : Convert.ToBase64String(binding);
}
}`
Replaced
private const string SessionIdClaim = "Microsoft.AspNetCore.Authentication.Cookies-SessionId";
with new property
/// <summary>
/// Added this to overwrite default SessionIdClaim value
/// </summary>
public virtual string SessionIdClaimType
{
get { return string.IsNullOrEmpty(Options.SessionIdClaim) ? SessionIdClaim : Options.SessionIdClaim; }
}
Added new extension method to use ExtendedCookieAuthenticationHandler.
public static class CookieExtentions
{
public static AuthenticationBuilder AddExtendedCookie(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<ExtendedCookieAuthenticationOptions> configureOptions)
{
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<ExtendedCookieAuthenticationOptions>, PostConfigureCookieAuthenticationOptions>());
return builder.AddScheme<ExtendedCookieAuthenticationOptions, ExtendedCookieAuthenticationHandler>(authenticationScheme, displayName, configureOptions);
}
}
Used new extension method in ConfigureServices method in startup.cs
.AddExtendedCookie("AuthScheme", "DisplayName", options =>
{
options.Cookie.Name = "CookieName";
options.Cookie.Domain = ".domain.com";
options.Cookie.HttpOnly = true;
options.SlidingExpiration = true;
options.Events = new CookieAuthenticationEvents()
{
//Sample how to add additional check for logged in User at Application Level.
OnValidatePrincipal = async context => { await ValidateAsync(context); },
};
options.LoginPath = "/account/login";
options.CookieManager = new ChunkingCookieManager();
options.SessionIdClaim = "Microsoft.Owin.Security.Cookies-SessionId";
options.TicketDataFormat = ticketDataFormat;
//SessionStore is configured in PostConfigureCookieAuthenticationOptions with DI
//options.SessionStore = //From DI
});

Async video streaming in ASP.Net Core Web Api is not working

I have used http://www.strathweb.com/2013/01/asynchronously-streaming-video-with-asp-net-web-api/ this technique before and worked perfect for async video streaming.
But for ASP.NET Core this way is not working as expected.
By Video streaming class is:
public class VideoStream
{
private readonly string _filename;
public VideoStream(string filename)
{
_filename = filename;
}
public async Task WriteToStream(Stream outputStream, HttpContent content, TransportContext context)
{
try
{
var buffer = new byte[65536];
using (var video = File.Open(_filename, FileMode.Open, FileAccess.Read))
{
var length = (int)video.Length;
var bytesRead = 1;
while (length > 0 && bytesRead > 0)
{
bytesRead = video.Read(buffer, 0, Math.Min(length, buffer.Length));
await outputStream.WriteAsync(buffer, 0, bytesRead);
length -= bytesRead;
}
}
}
catch (Exception)
{ return; }
finally
{
outputStream.Flush();
outputStream.Dispose();
}
}
}
and I have the following Action for video streaming requests:
[HttpGet]
[Route("[action]")]
public IActionResult GetVideo(int id)
{
var fileName = GetVideoFileName(id);
var video = new VideoStream(fileName);
var response = new HttpResponseMessage
{
Content = new PushStreamContent(video.WriteToStream, new MediaTypeHeaderValue("video/mp4"))
};
var objectResult = new ObjectResult(response);
objectResult.ContentTypes.Add(new Microsoft.Net.Http.Headers.MediaTypeHeaderValue("video/mp4"));
return objectResult;
}
Since by default Asp.Net Core doesn't have built-in Media Formatter for video/mp4 I have created the following custom Media Formatter
public class VideoOutputFormatter : IOutputFormatter
{
public bool CanWriteResult(OutputFormatterCanWriteContext context)
{
if (context == null)
throw new ArgumentNullException(nameof(context));
return true;
}
public async Task WriteAsync(OutputFormatterWriteContext context)
{
if (context == null)
throw new ArgumentNullException(nameof(context));
var response = context.HttpContext.Response;
response.ContentType = "video/mp4";
How to impelemnt ???
}
}
and added the following line to Startup.cs
services.AddMvc(options =>
{
options.OutputFormatters.Add(new VideoOutputFormatter());
});
It actually calls my custom formatter.
I doesn't know how to implement this custom media formatter for video/mp4.
Anyone can help me ?
Looking at the source code for Asp.NET Core really helped me find the answer to this one. They have a StreamOutputFormatter class that's really close to what you want to use. I only had to modify it to look for PushStreamContent and it worked like a charm.
Here's my complete VideoOutputFormatter:
public class VideoOutputFormatter : IOutputFormatter
{
public bool CanWriteResult(OutputFormatterCanWriteContext context)
{
if (context == null)
throw new ArgumentNullException(nameof(context));
if (context.Object is PushStreamContent)
return true;
return false;
}
public async Task WriteAsync(OutputFormatterWriteContext context)
{
if (context == null)
throw new ArgumentNullException(nameof(context));
using (var stream = ((PushStreamContent)context.Object))
{
var response = context.HttpContext.Response;
if (context.ContentType != null)
{
response.ContentType = context.ContentType.ToString();
}
await stream.CopyToAsync(response.Body);
}
}
}
Instead of wrapping the HttpResponseMessage in the ObjectResult in your controller, you'll want to just shove the PushStreamContent object into the ObjectResult instead. You still need to set the MediaTypeHeaderValue on the ObjectResult.

ServiceStack REST API path variables from root throwing exception

I am trying to write a REST web service using ServiceStack that accepts variable paths off of route. For example:
[Route("/{group}"]
public class Entity : IReturn<SomeType> {}
This throws a NotSupported Exception "RestPath '/{collection}' on type Entity is not supported". However, if I change the path as follows (along with the associated path in AppHost configuration) to:
[Route("/rest/{group}"]
It works just fine. In order to integrate with the system that I am working with, I need to use /{group}.
ServiceStack now allows you to add a fallback route from the / root path to handle any un-matched requests, that's not handled by a catch-all handler or refers to an existing static file. So in v3.9.56 you can now do:
[FallbackRoute("/{group}"]
public class Entity : IReturn<SomeType> {}
An alternative option is to register a IAppHost.CatchAllHandlers to handle un-matched routes, but you would need to return your own IHttpHandler to handle the request or alternatively return a RedirectHttpHandler to redirect to a different route that is managed by ServiceStack.
My current work in progress, a plugin to serve the default page to all 'not found' routes without changing the url in the browser, includes most of what you'll need to handle a global wildcard route. Use it to get you started.
To understand what this code is doing, it helps to understand ServiceStack's routing priority, and how CatchAllHandlers fit into the process. ServiceStack calls ServiceStackHttpHandlerFactory.GetHandler to get the handler for the current route.
ServiceStackHttpHandlerFactory.GetHandler returns:
A matching RawHttpHandler, if any.
If the domain root, the handler returned by GetCatchAllHandlerIfAny(...), if any.
If the route matches a metadata uri (I'm skipping over the exact logic here, as it's not important for your question), the relevant handler, if any.
The handler returned by ServiceStackHttpHandlerFactory.GetHandlerForPathInfo if any.
NotFoundHandler.
ServiceStackHttpHandlerFactory.GetHandlerForPathInfo returns:
If the url matches a valid REST route, a new RestHandler.
If the url matches an existing file or directory, it returns
the handler returned by GetCatchAllHandlerIfAny(...), if any.
If it's a supported filetype, a StaticFileHandler,
If it's not a supported filetype, the ForbiddenHttpHandler.
The handler returned by GetCatchAllHandlerIfAny(...), if any.
null.
The CatchAllHandlers array contains functions that evaluate the url and either return a handler, or null. The functions in the array are called in sequence and the first one that doesn't return null handles the route. Let me highlight some key elements:
First, the plugin adds a CatchAllHandler to the appHost.CatchAllHandlers array when registered.
public void Register(IAppHost appHost)
{
appHost.CatchAllHandlers.Add((string method, string pathInfo, string filepath) =>
Factory(method, pathInfo, filepath));
}
Second, the CatchAllHandler. As described above, the function may be called for the domain root, an existing file or directory, or any other unmatched route. Your method should return a handler, if your criteria are met, or return null.
private static Html5ModeFeature Factory(String method, String pathInfo, String filepath)
{
var Html5ModeHandler = Html5ModeFeature.Instance;
List<string> WebHostRootFileNames = RootFiles();
// handle domain root
if (string.IsNullOrEmpty(pathInfo) || pathInfo == "/")
{
return Html5ModeHandler;
}
// don't handle 'mode' urls
var mode = EndpointHost.Config.ServiceStackHandlerFactoryPath;
if (mode != null && pathInfo.EndsWith(mode))
{
return null;
}
var pathParts = pathInfo.TrimStart('/').Split('/');
var existingFile = pathParts[0].ToLower();
var catchAllHandler = new Object();
if (WebHostRootFileNames.Contains(existingFile))
{
var fileExt = Path.GetExtension(filepath);
var isFileRequest = !string.IsNullOrEmpty(fileExt);
// don't handle directories or files that have another handler
catchAllHandler = GetCatchAllHandlerIfAny(method, pathInfo, filepath);
if (catchAllHandler != null) return null;
// don't handle existing files under any event
return isFileRequest ? null : Html5ModeHandler;
}
// don't handle non-physical urls that have another handler
catchAllHandler = GetCatchAllHandlerIfAny(method, pathInfo, filepath);
if (catchAllHandler != null) return null;
// handle anything else
return Html5ModeHandler;
}
In the case of the wildcard at the root domain, you may not want to hijack routes that can be handled by another CatchAllHandler. If so, to avoid infinite recursion, you'll need a custom GetCatchAllHandlerIfAny method.
//
// local copy of ServiceStackHttpHandlerFactory.GetCatchAllHandlerIfAny, prevents infinite recursion
//
private static IHttpHandler GetCatchAllHandlerIfAny(string httpMethod, string pathInfo, string filePath)
{
if (EndpointHost.CatchAllHandlers != null)
{
foreach (var httpHandlerResolver in EndpointHost.CatchAllHandlers)
{
if (httpHandlerResolver == Html5ModeFeature.Factory) continue; // avoid infinite recursion
var httpHandler = httpHandlerResolver(httpMethod, pathInfo, filePath);
if (httpHandler != null)
return httpHandler;
}
}
return null;
}
Here's the complete, and completely untested, plugin. It compiles. It carries no warranty of fitness for any specific purpose.
using ServiceStack;
using ServiceStack.Common.Web;
using ServiceStack.Razor;
using ServiceStack.ServiceHost;
using ServiceStack.Text;
using ServiceStack.WebHost.Endpoints;
using ServiceStack.WebHost.Endpoints.Formats;
using ServiceStack.WebHost.Endpoints.Support;
using ServiceStack.WebHost.Endpoints.Support.Markdown;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Web;
namespace MyProject.Support
{
public enum DefaultFileFormat
{
Markdown,
Razor,
Static
}
public class Html5ModeFeature : EndpointHandlerBase, IPlugin
{
private FileInfo fi { get; set; }
private DefaultFileFormat FileFormat { get; set; }
private DateTime FileModified { get; set; }
private byte[] FileContents { get; set; }
public MarkdownHandler Markdown { get; set; }
public RazorHandler Razor { get; set; }
public object Model { get; set; }
private static Dictionary<string, string> allDirs;
public string PathInfo { get; set; }
public void Register(IAppHost appHost)
{
appHost.CatchAllHandlers.Add((string method, string pathInfo, string filepath) =>
Factory(method, pathInfo, filepath));
}
private Html5ModeFeature()
{
foreach (var defaultDoc in EndpointHost.Config.DefaultDocuments)
{
if (PathInfo == null)
{
var defaultFileName = Path.Combine(Directory.GetCurrentDirectory(), defaultDoc);
if (!File.Exists(defaultFileName)) continue;
PathInfo = (String)defaultDoc; // use first default document found.
}
}
SetFile();
}
private static Html5ModeFeature instance;
public static Html5ModeFeature Instance
{
get { return instance ?? (instance = new Html5ModeFeature()); }
}
public void SetFile()
{
if (PathInfo.EndsWith(MarkdownFormat.MarkdownExt) || PathInfo.EndsWith(MarkdownFormat.TemplateExt))
{
Markdown = new MarkdownHandler(PathInfo);
FileFormat = DefaultFileFormat.Markdown;
return;
}
if (PathInfo.EndsWith(Razor.RazorFormat.RazorFileExtension)) {
Razor = new RazorHandler(PathInfo);
FileFormat = DefaultFileFormat.Razor;
return;
}
FileContents = File.ReadAllBytes(PathInfo);
FileModified = File.GetLastWriteTime(PathInfo);
FileFormat = DefaultFileFormat.Static;
}
//
// ignore request.PathInfo, return default page, extracted from StaticFileHandler.ProcessResponse
//
public void ProcessStaticPage(IHttpRequest request, IHttpResponse response, string operationName)
{
response.EndHttpHandlerRequest(skipClose: true, afterBody: r =>
{
TimeSpan maxAge;
if (r.ContentType != null && EndpointHost.Config.AddMaxAgeForStaticMimeTypes.TryGetValue(r.ContentType, out maxAge))
{
r.AddHeader(HttpHeaders.CacheControl, "max-age=" + maxAge.TotalSeconds);
}
if (request.HasNotModifiedSince(fi.LastWriteTime))
{
r.ContentType = MimeTypes.GetMimeType(PathInfo);
r.StatusCode = 304;
return;
}
try
{
r.AddHeaderLastModified(fi.LastWriteTime);
r.ContentType = MimeTypes.GetMimeType(PathInfo);
if (fi.LastWriteTime > this.FileModified)
SetFile(); //reload
r.OutputStream.Write(this.FileContents, 0, this.FileContents.Length);
r.Close();
return;
}
catch (Exception ex)
{
throw new HttpException(403, "Forbidden.");
}
});
}
private void ProcessServerError(IHttpRequest httpReq, IHttpResponse httpRes, string operationName)
{
var sb = new StringBuilder();
sb.AppendLine("{");
sb.AppendLine("\"ResponseStatus\":{");
sb.AppendFormat(" \"ErrorCode\":{0},\n", 500);
sb.AppendFormat(" \"Message\": HTML5ModeHandler could not serve file {0}.\n", PathInfo.EncodeJson());
sb.AppendLine("}");
sb.AppendLine("}");
httpRes.EndHttpHandlerRequest(skipClose: true, afterBody: r =>
{
r.StatusCode = 500;
r.ContentType = ContentType.Json;
var sbBytes = sb.ToString().ToUtf8Bytes();
r.OutputStream.Write(sbBytes, 0, sbBytes.Length);
r.Close();
});
return;
}
private static List<string> RootFiles()
{
var WebHostPhysicalPath = EndpointHost.Config.WebHostPhysicalPath;
List<string> WebHostRootFileNames = new List<string>();
foreach (var filePath in Directory.GetFiles(WebHostPhysicalPath))
{
var fileNameLower = Path.GetFileName(filePath).ToLower();
WebHostRootFileNames.Add(Path.GetFileName(fileNameLower));
}
foreach (var dirName in Directory.GetDirectories(WebHostPhysicalPath))
{
var dirNameLower = Path.GetFileName(dirName).ToLower();
WebHostRootFileNames.Add(Path.GetFileName(dirNameLower));
}
return WebHostRootFileNames;
}
private static Html5ModeFeature Factory(String method, String pathInfo, String filepath)
{
var Html5ModeHandler = Html5ModeFeature.Instance;
List<string> WebHostRootFileNames = RootFiles();
// handle domain root
if (string.IsNullOrEmpty(pathInfo) || pathInfo == "/")
{
return Html5ModeHandler;
}
// don't handle 'mode' urls
var mode = EndpointHost.Config.ServiceStackHandlerFactoryPath;
if (mode != null && pathInfo.EndsWith(mode))
{
return null;
}
var pathParts = pathInfo.TrimStart('/').Split('/');
var existingFile = pathParts[0].ToLower();
var catchAllHandler = new Object();
if (WebHostRootFileNames.Contains(existingFile))
{
var fileExt = Path.GetExtension(filepath);
var isFileRequest = !string.IsNullOrEmpty(fileExt);
// don't handle directories or files that have another handler
catchAllHandler = GetCatchAllHandlerIfAny(method, pathInfo, filepath);
if (catchAllHandler != null) return null;
// don't handle existing files under any event
return isFileRequest ? null : Html5ModeHandler;
}
// don't handle non-physical urls that have another handler
catchAllHandler = GetCatchAllHandlerIfAny(method, pathInfo, filepath);
if (catchAllHandler != null) return null;
// handle anything else
return Html5ModeHandler;
}
//
// Local copy of private StaticFileHandler.DirectoryExists
//
public static bool DirectoryExists(string dirPath, string appFilePath)
{
if (dirPath == null) return false;
try
{
if (!ServiceStack.Text.Env.IsMono)
return Directory.Exists(dirPath);
}
catch
{
return false;
}
if (allDirs == null)
allDirs = CreateDirIndex(appFilePath);
var foundDir = allDirs.ContainsKey(dirPath.ToLower());
//log.DebugFormat("Found dirPath {0} in Mono: ", dirPath, foundDir);
return foundDir;
}
//
// Local copy of private StaticFileHandler.CreateDirIndex
//
static Dictionary<string, string> CreateDirIndex(string appFilePath)
{
var indexDirs = new Dictionary<string, string>();
foreach (var dir in GetDirs(appFilePath))
{
indexDirs[dir.ToLower()] = dir;
}
return indexDirs;
}
//
// Local copy of private StaticFileHandler.GetDirs
//
static List<string> GetDirs(string path)
{
var queue = new Queue<string>();
queue.Enqueue(path);
var results = new List<string>();
while (queue.Count > 0)
{
path = queue.Dequeue();
try
{
foreach (string subDir in Directory.GetDirectories(path))
{
queue.Enqueue(subDir);
results.Add(subDir);
}
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
}
}
return results;
}
//
// local copy of ServiceStackHttpHandlerFactory.GetCatchAllHandlerIfAny, prevents infinite recursion
//
private static IHttpHandler GetCatchAllHandlerIfAny(string httpMethod, string pathInfo, string filePath)
{
if (EndpointHost.CatchAllHandlers != null)
{
foreach (var httpHandlerResolver in EndpointHost.CatchAllHandlers)
{
if (httpHandlerResolver == Html5ModeFeature.Factory) continue; // avoid infinite recursion
var httpHandler = httpHandlerResolver(httpMethod, pathInfo, filePath);
if (httpHandler != null)
return httpHandler;
}
}
return null;
}
public override void ProcessRequest(IHttpRequest httpReq, IHttpResponse httpRes, string operationName)
{
switch (FileFormat)
{
case DefaultFileFormat.Markdown:
{
Markdown.ProcessRequest(httpReq, httpRes, operationName);
break;
}
case DefaultFileFormat.Razor:
{
Razor.ProcessRequest(httpReq, httpRes, operationName);
break;
}
case DefaultFileFormat.Static:
{
fi.Refresh();
if (fi.Exists) ProcessStaticPage(httpReq, httpRes, operationName); else ProcessServerError(httpReq, httpRes, operationName);
break;
}
default:
{
ProcessServerError(httpReq, httpRes, operationName);
break;
}
}
}
public override object CreateRequest(IHttpRequest request, string operationName)
{
return null;
}
public override object GetResponse(IHttpRequest httpReq, IHttpResponse httpRes, object request)
{
return null;
}
}
}

Resources