June 1, 2016

I've learned a lot about Asp.Net Core RC2, Kestrel and ports as I failed in various attempts to get a http to https redirect. I share this as a retrospective for myself and in hopes of helping you avoid the swamp and to stay on the narrow road.

Update: This approach works with the 1.0 release of Asp.Net Core as well as RC2.

Goal

Host Asp.Net with Kestrel without IIS for an internal app. I'd like to hit the machine at http://machine-name/ and get redirected to a login at https://machine-name without ports.

If they hit http(s)://machine-name/index.html without an authentication cookie, it must redirect them to https://machine-name to login.

Note: The official recommendation is to use IIS or nginx, but I want to avoid installing it on these machines and this will be internal network access. In the past I've relied on IIS and "defaults on http and https being 80 and 443 and hidden port magic" as Ben Adams on the #kestrel channel aspnetcore.slack.com put it. Therein pointing out another abstraction Microsoft has helped me out with for many years and something I need to learn more about.

Tools I'm using

  • Asp.Net Core RC2
  • Kestrel
  • Asp.Net Identity
  • Visual Studio 2015 Update 2
  • dotnet Command line interface (http://dot.net)
  • DurandalJs
  • Chrome
  • the internet for searching and asking
  • http://aspnetcore.slack.com
  • http://github.com/aspnet

Moving to Middleware setup from old Global.asax

I am excited about the possibilities having complete control over choosing what I want to use for my app, but it takes more tweaking and learning then I was initially prepared for.

  • Order matters - the middleware is executed in order of how it is declared in the code.
  • It's sometimes difficult to find an example for what I want to do. Core RC2 is only a few weeks old, there are samples on github.com, but I have done a lot of Bingling and asking to find information to get past my first pre-suppositions on how things should work.
  • I need to understand things more deeply (this is a good thing). See my incorrect thinking in my "issue".

Tooling with Visual Studio

Something has gone wrong when I installed the RC2 update and now Visual Studio isn't building the project correctly. Hopefully you don't get this. I've been opening a console, running dotnet build (which is taking 10-20 seconds for a smaller net461 app), then F5 in Visual Studio to debug. I'm sure this will improve with the next releases. The nice thing is that it knows if nothing has changed and skips compiliing if it hasn't

The create new Asp.net website dialog has also stopped working. I'll have to do a Visual Studio repair to see if that fixes when I remember to start it during lunch.

Networking, Ports And External Access

The nestat is a useful for EADDRInUse errors and see who is using what ports.

netstat -ano | findstr :80 ####Experiment with .UseUrls("http://:9000", "https://:9001") ~~~ info: Microsoft.AspNetCore.Hosting.Internal.WebHost[1] Request starting HTTP/1.1 GET http://localhost:9000/ warn: Microsoft.AspNetCore.Mvc.Internal.ControllerActionInvoker[1] Authorization failed for the request at filter 'Microsoft.AspNetCore.Mvc.R equireHttpsAttribute'. info: Microsoft.AspNetCore.Mvc.RedirectResult[1] Executing RedirectResult, redirecting to https://localhost/. info: Microsoft.AspNetCore.Mvc.Internal.MvcRouteHandler[2] Executed action Daktronics.BridgeConfigUi.Controllers.AuthController.Login (web) in 1.9361ms info: Microsoft.AspNetCore.Hosting.Internal.WebHost[2] Request finished in 5.2157ms 302 -- note: I'm not sure what this means info: Microsoft.AspNetCore.Server.Kestrel[17] Connection id "0HKSA7DEPD5LA" bad request data: "Malformed request: Method Incomplete" Microsoft.AspNetCore.Server.Kestrel.Exceptions.BadHttpRequestException: Malforme d request: MethodIncomplete warn: Microsoft.AspNetCore.Server.Kestrel[0] Connection processing ended abnormally Microsoft.AspNetCore.Server.Kestrel.Exceptions.BadHttpRequestException: Malforme d request: MethodIncomplete at Microsoft.AspNetCore.Server.Kestrel.Http.Frame.RejectRequest(String messag e) at Microsoft.AspNetCore.Server.Kestrel.Http.Frame`1.d __2.MoveNext()


  ####Experiment with .UseUrls("http://*:9050", "https://*:9051")

C:\WINDOWS\system32>netstat -ano | findstr :9050 TCP 0.0.0.0:9050 0.0.0.0:0 LISTENING 9060 TCP [::]:9050 [::]:0 LISTENING 9060

C:\WINDOWS\system32>netstat -ano | findstr :9051 TCP 0.0.0.0:9051 0.0.0.0:0 LISTENING 9060 TCP [::]:9051 [::]:0 LISTENING 9060

Experiment with .UseUrls("http://*:800", "https://*:4430")

C:\WINDOWS\system32>netstat -ano | findstr :4430 TCP 0.0.0.0:4430 0.0.0.0:0 LISTENING 7332 TCP [::]:4430 [::]:0 LISTENING 7332

C:\WINDOWS\system32>netstat -ano | findstr :800 TCP 0.0.0.0:800 0.0.0.0:0 LISTENING 7332 TCP [::]:800 [::]:0 LISTENING 7332 ~~~

External hosting

I wasn't sure why listening on port 80 or 443 wasn't doing the trick. Again more things IIS was doing for me in the past. Here are a few areas I looked open up the port 80, 443? http://stackoverflow.com/questions/7363470/windows-server-2008-r2-cant-get-apache-to-run-on-port-80

http://stackoverflow.com/questions/34212765/how-do-i-get-the-kestrel-web-server-to-listen-to-non-localhost-requests

My GitHub Issue with quick responses from the team "You need to remove all the netsh entries and disable IIS if you want Kestrel to listen on those ports. Kestrel doesn't use netsh/http.sys and can't share ports with it." I wasn't 100% sure what to here, but ran dotnet publish and copied a zip of that to the VM and it works as I want it. I can browse to http://vm-3/ from my computer.

Where I ended up

I ended up using both ports and default ports as an option. On my dev laptop I had some issues with other computers accessing it through my computer name, but it worked fine on a VM. So I'm putting both options in to make it accessable.

  • I'm letting MVC handle most of the redirecting setup in Startup.cs ConfigureServices. RequireHttpsAttribute forces all controllers to https://localhost.
    • Note: this does not work correctly if not port 80 and port 443. http://localhost:9000 was getting redirected to https://localhost and not working. I didn't find how to configure this. The best alternative I can think of is to remove the RequireHttpsAttribute and do the redirect manually in app.Use intercept of the request.
  • I'm manually forcing an unauthorized https://localhost/index.html back to the login page through the app.Use(async (httpContext, next) in the Configure method. I had to make sure this was after app.UseMvc and before app.UseStaticFiles();
  • I'm then putting the AuthorizeFilter on all controllers with config.Filters.Add
services.AddMvc(config =>
{
    config.Filters.Add(new RequireHttpsAttribute());

    // http://stackoverflow.com/questions/36413476/mvc6-how-to-force-set-global-authorization-for-all-actions
    var policy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build();
    config.Filters.Add(new AuthorizeFilter(policy));
})
  • Lastly, I put attributes in the AuthController to allow anonymous and require authentication on the correct methods.

Here is the Kestrel Console Output of it doing the redirect: Kestrel Console Output

My Startup.cs, it can also be in Program.cs as some examples have it.

9000 and 9001 are my backups just in case. I'm considering removing them, but if a VM that we are installing this has the same issue as my computer I wanted to leave in the option. ~~~ public static void Main(string[] args) { var wwwRootPath = Directory.GetCurrentDirectory() + @"\wwwroot"; var testCertPath = Path.Combine(wwwRootPath, @"config.pfx"); //var h = new WebHostBuilder(); //var environment = h.GetSetting("environment");

var builder = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json");
var configuration = builder.Build();

// allow localhost or http://machine-name or http://machine-name:9000
var host = new WebHostBuilder()
    .UseUrls( "http://*:80", "https://*:443")
    .UseKestrel(options =>
    {
        options.NoDelay = true;
        options.UseHttps(testCertPath, configuration["pfxPassword"]);
        options.UseConnectionLogging();
    })
    .UseContentRoot(Directory.GetCurrentDirectory())
    .UseStartup()
    .Build();
host.Run();

} ~~~

Startup.cs Configure method

app.UseIdentity();
app.UseMvc(config =>
{
    config.MapRoute(
      name: "Default",
      template: "{controller}/{action}/{id?}",
      defaults: new { controller = "Auth", action = "Login" }
      );
});

// http://www.talkingdotnet.com/app-use-vs-app-run-asp-net-core-middleware/
app.Use(async (httpContext, next) =>
{
    var url = httpContext.Request.GetDisplayUrl();
    if (!url.Contains("png"))
    {
        // http://stackoverflow.com/questions/36987688/how-do-i-add-no-cache-to-kestrel-responses/
        httpContext.Response.Headers[HeaderNames.CacheControl] = "no-store";
    }

    if (!signInManager.IsSignedIn(httpContext.User))
    {
        if (url.Contains("index"))
        {
            httpContext.Response.Redirect("/");
            return;
        }
    }

    await next();
});

// Add static files to the request pipeline.
app.UseStaticFiles();

Parts of the ConfigureServices

~~~ public void ConfigureServices(IServiceCollection services) { ITraceWriter traceWriter = new DiagnosticsTraceWriter(); services.AddMvc(config => { config.Filters.Add(new RequireHttpsAttribute());

    // http://stackoverflow.com/questions/36413476/mvc6-how-to-force-set-global-authorization-for-all-actions
    var policy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
            .Build();
    config.Filters.Add(new AuthorizeFilter(policy));
})
// http://www.strathweb.com/2014/11/formatters-asp-net-mvc-6/
// configure to return camelCased JSON
.AddJsonOptions(options =>
{
    options.SerializerSettings.TraceWriter = traceWriter;
    options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
});
services

.AddDbContext(options => { // more code here... });

services.AddIdentity(config =>
{
    config.Password.RequiredLength = 8;
    config.Password.RequireDigit = false;
    config.Password.RequireNonAlphanumeric = true;
    config.Cookies.ApplicationCookie.CookieSecure = CookieSecureOption.Always;
    config.Cookies.ApplicationCookie.SlidingExpiration = true;
    config.Cookies.ApplicationCookie.ExpireTimeSpan = TimeSpan.FromHours(1);
    config.Cookies.ApplicationCookie.LoginPath = "/";
    config.Cookies.ApplicationCookie.Events = new CookieAuthenticationEvents()
    {
        OnRedirectToLogin = ctx =>
        {
            if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
            {
                ctx.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
            }
            else
            {
                ctx.Response.Redirect(ctx.RedirectUri);
            }

            return Task.FromResult(0);
        }
    };
})
.AddEntityFrameworkStores();

~~~

and my AuthController for good measure. Notice the authorize attributes I used.

~~~ [AllowAnonymous] public class AuthController : Controller { private readonly SignInManager _signInManager; private readonly UserManager _userManager;

public AuthController(UserManager userManager, SignInManager signInManager)
{
    this._userManager = userManager;
    this._signInManager = signInManager;
}

public IActionResult Login()
{
    if (this.User.Identity.IsAuthenticated)
    {
        return this.Redirect("index.html");
    }

    return this.View();
}

[HttpPost]
public async Task Login(LoginViewModel vm)
{
    if (!this.ModelState.IsValid)
    {
        return this.View();
    }

    var signInResult = await this._signInManager.PasswordSignInAsync(
        vm.Username,
        vm.Password,
        true, false);

    if (signInResult.Succeeded)
    {
        return this.Redirect("index.html");
    }

    this.ModelState.AddModelError("", "Username or password incorrect");
    return this.View();
}

// API calls
[Authorize]
[HttpPost]
[Route("auth/changeUserNamePassword")]
public async Task ChangeUserNamePassword([FromBody] ChangePasswordViewModel model)
{
    if (!this.ModelState.IsValid)
    {
        return this.Ok(false);
    }

    var user = await this._userManager.GetUserAsync(this.User);
    if (user == null) return this.Unauthorized();

    var changeUserNameResult = new IdentityResult();
    if (user.UserName != model.Username)
    {
        user.UserName = model.Username;
        changeUserNameResult = await this._userManager.UpdateAsync(user);
    }

    var result = await this._userManager.ChangePasswordAsync(user, model.OldPassword, model.NewPassword);
    if (!result.Succeeded || !changeUserNameResult.Succeeded) return this.Ok(false);

    await this._signInManager.SignInAsync(user, isPersistent: false);
    return this.Ok(true);
}

[Authorize]
[HttpGet]
[Route("auth/getUserName")]
public IActionResult GetUserName()
{
    return this.Ok(this._userManager.GetUserName(this.User));
}

[Authorize]
[HttpGet]
[Route("auth/logout")]
public async Task Logout()
{
    if (this.User.Identity.IsAuthenticated)
    {
        await this._signInManager.SignOutAsync();
    }

    return this.Ok();
}

} ~~~