Ejemplo n.º 1
0
        public async Task Invoke(HttpContext httpContext)
        {
            // 0. This is the first entry point of ASP middleware. It is the first in the chain. Before that, there is only settings, like Rewrite() and Https redirection.
            if (httpContext == null)
            {
                throw new ArgumentNullException(nameof(httpContext));
            }

            // 1. Do whitelist check first. That will sort out the most number of bad requests. Always do that filter First that results the most refusing. Try to consume less resources, so don't log it to file.
            if (!IsHttpRequestOnWhitelist(httpContext))
            {
                // Console.WriteLine($"SqFirewall: request '{httpContext.Request.Host}' '{httpContext.Request.Path}' is not on whitelist.");
                // return Unauthorized();  // can only be used in Controller. https://github.com/aspnet/Mvc/blob/rel/1.1.1/src/Microsoft.AspNetCore.Mvc.Core/ControllerBase.cs
                httpContext.Response.StatusCode = StatusCodes.Status410Gone;  // '410 Gone' is better than '404 Not Found'. Client will not request it later. See https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
                await httpContext.Response.WriteAsync($"SqFirewall: request '{httpContext.Request.Host}' '{httpContext.Request.Path}' is not on whitelist.", Encoding.UTF8);

                return;
            }

            // 2. Do blacklists too, because whitelist might check only for prefixes. Don't push it to the next Middleware if the path or IP is on the blacklist. In the future, implement a whitelist too, and only allow  requests explicitely on the whitelist.
            if (IsClientIpOrPathOnBlacklist(httpContext))
            {
                // silently log it and stop processing
                // string msg = String.Format($"{DateTime.UtcNow.ToString("HH':'mm':'ss.f")}#Blacklisted request is terminated: {httpContext.Request.Method} '{httpContext.Request.Path}' from {WsUtils.GetRequestIPv6(httpContext)}");
                // Console.WriteLine(msg);
                // gLogger.Info(msg);
                httpContext.Response.StatusCode = StatusCodes.Status410Gone;  // '410 Gone' is better than '404 Not Found'. Client will not request it later. See https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
                await httpContext.Response.WriteAsync($"SqFirewall: request '{httpContext.Request.Path}' is on blacklist.", Encoding.UTF8);

                return;
            }

            Exception?exception = null;
            DateTime  startTime = DateTime.UtcNow;
            var       sw        = Stopwatch.StartNew();

            try
            {
                // If crashing in query "/signin-google", then see comment in Startup.cs:OnRemoteFailure
                // if (httpContext.Request.Path.ToString().StartsWith("/signin-google"))
                //     Utils.Logger.Info("SqFirewallMiddlewarePreAuthLogger._next() will be called to check Google Authentication.");
                await _next(httpContext);   // continue in middleware app.UseAuthentication();
            }
            catch (Exception e)
            {
                // when NullReference exception was raised in TestHealthMonitorEmailByRaisingException(), The exception didn't fall to here. if
                // It was handled already and I got a nice Error page to the client. So, here, we don't have the exceptions and exception messages and the stack trace.
                exception = e;

                Utils.Logger.Error(e, "SqFirewallMiddlewarePreAuthLogger._next() middleware");
                if (e.InnerException != null)
                {
                    Utils.Logger.Error(e, "SqFirewallMiddlewarePreAuthLogger._next() middleware. InnerException.");
                }
                throw;
            }
            finally
            {
                sw.Stop();                                              // Kestrel measures about 50ms more overhead than this measurement. Add 50ms more to estimate reaction time.

                var statusCode      = httpContext.Response?.StatusCode; // it may be null if there was an Exception
                var level           = statusCode > 499 ? Microsoft.Extensions.Logging.LogLevel.Error : Microsoft.Extensions.Logging.LogLevel.Information;
                var clientIP        = WsUtils.GetRequestIPv6(httpContext);
                var clientUserEmail = WsUtils.GetRequestUser(httpContext);

                var requestLog = new HttpRequestLog()
                {
                    StartTime         = DateTime.UtcNow,
                    IsHttps           = httpContext.Request.IsHttps,
                    Method            = httpContext.Request.Method,
                    Host              = httpContext.Request.Host, // "sqcore.net" for main, "dashboard.sqcore.net" for sub-domain queries
                    Path              = httpContext.Request.Path,
                    QueryString       = httpContext.Request.QueryString.ToString(),
                    ClientIP          = clientIP,
                    ClientUserEmail   = clientUserEmail,
                    StatusCode        = statusCode,
                    TotalMilliseconds = sw.Elapsed.TotalMilliseconds,
                    IsError           = exception != null || (level == Microsoft.Extensions.Logging.LogLevel.Error),
                    Exception         = exception
                };
                lock (Program.g_webAppGlobals.HttpRequestLogs)  // prepare for multiple threads
                {
                    Program.g_webAppGlobals.HttpRequestLogs.Enqueue(requestLog);
                    while (Program.g_webAppGlobals.HttpRequestLogs.Count > 50 * 10)  // 2018-02-19: MaxHttpRequestLogs was 50, but changed to 500, because RTP (RealTimePrice) rolls 50 items out after 2 hours otherwise. 500 items will last for 20 hours.
                    {
                        Program.g_webAppGlobals.HttpRequestLogs.Dequeue();
                    }
                }

                // $"{DateTime.UtcNow.ToString("MMdd'T'HH':'mm':'ss.fff")}#

                // string.Format("Value is {0}", someValue) which will check for a null reference and replace it with an empty string. It will however throw an exception if you actually pass  null like this string.Format("Value is {0}", null)
                string msg = String.Format("PreAuth.Postprocess: Returning {0}#{1}{2} {3} '{4} {5}' from {6} (u: {7}) ret: {8} in {9:0.00}ms", requestLog.StartTime.ToString("HH':'mm':'ss.f"), requestLog.IsError ? "ERROR in " : string.Empty, requestLog.IsHttps ? "HTTPS" : "HTTP", requestLog.Method, requestLog.Host, requestLog.Path, requestLog.ClientIP, requestLog.ClientUserEmail, requestLog.StatusCode, requestLog.TotalMilliseconds);
                // string shortMsg = String.Format("{0}#{1} {2} '{3} {4}' from {5} ({6}) in {7:0.00}ms", requestLog.StartTime.ToString("HH':'mm':'ss.f"), requestLog.IsError ? "ERROR in " : string.Empty, requestLog.Method, requestLog.Host, requestLog.Path, requestLog.ClientIP, requestLog.ClientUserEmail, requestLog.TotalMilliseconds);
                // Console.WriteLine(shortMsg);
                gLogger.Info(msg);

                if (requestLog.IsError)
                {
                    LogDetailedContextForError(httpContext, requestLog);
                }

                // at the moment, send only raised Exceptions to HealthMonitor, not general IsErrors, like wrong statusCodes
                if (requestLog.Exception != null && IsSendableToHealthMonitorForEmailing(requestLog.Exception))
                {
                    StringBuilder sb            = new StringBuilder("Exception in SqCore.Website.C#.SqFirewallMiddlewarePreAuthLogger. \r\n");
                    var           requestLogStr = String.Format("{0}#{1}{2} {3} '{4}' from {5} (u: {6}) ret: {7} in {8:0.00}ms", requestLog.StartTime.ToString("HH':'mm':'ss.f"), requestLog.IsError ? "ERROR in " : string.Empty, requestLog.IsHttps ? "HTTPS" : "HTTP", requestLog.Method, requestLog.Path + (String.IsNullOrEmpty(requestLog.QueryString) ? string.Empty : requestLog.QueryString), requestLog.ClientIP, requestLog.ClientUserEmail, requestLog.StatusCode, requestLog.TotalMilliseconds);
                    sb.Append("Request: " + requestLogStr + "\r\n");
                    sb.Append("Exception: '" + requestLog.Exception.ToStringWithShortenedStackTrace(1600) + "'\r\n");
                    HealthMonitorMessage.SendAsync(sb.ToString(), HealthMonitorMessageID.SqCoreWebCsError).TurnAsyncToSyncTask();
                }
            }
        }
Ejemplo n.º 2
0
        public async Task Invoke(HttpContext httpContext)
        {
            if (httpContext == null)
            {
                throw new ArgumentNullException(nameof(httpContext));
            }

            // 1. checks user auth for some staticFiles (like HTMLs, Controller APIs), but not for everything (not jpg, CSS, JS)
            var userAuthCheck = WsUtils.CheckAuthorizedGoogleEmail(httpContext);

            if (userAuthCheck != UserAuthCheckResult.UserKnownAuthOK)
            {
                // It would be impossible task if subdomains are converted to path BEFORE this user auth check.
                // if "https://dashboard.sqcore.net" rewriten to  "https://sqcore.net/webapps/MarketDashboard/index.html" then login is //dashboard.sqcore.net/UserAccount/login
                // if "https://healthmonitor.sqcore.net" rewriten to.....
                // Otherwise, we redirect user to https://sqcore.net/UserAccount/login

                // if user is unknown or not allowed: log it but allow some files (jpeg) through, but not html or APIs


                string ext = Path.GetExtension(httpContext.Request.Path.Value) ?? string.Empty;
                bool   isAllowedRequest = false;

                if (ext.Equals(".html", StringComparison.OrdinalIgnoreCase) || ext.Equals(".htm", StringComparison.OrdinalIgnoreCase))   // 1. HTML requests
                {
                    // Allow without user login only for the main domain's index.html ("sqcore.net/index.html"),
                    // For subdomains, like "dashboard.sqcore.net/index.html" require UserLogin
                    if (((Program.g_webAppGlobals.KestrelEnv?.EnvironmentName == "Development") || httpContext.Request.Host.Host.StartsWith("sqcore.net")) &&
                        (httpContext.Request.Path.Value?.Equals("/index.html", StringComparison.OrdinalIgnoreCase) ?? false))
                    {                            // if it is HTML only allow '/index.html' through
                        isAllowedRequest = true; // don't replace raw main index.html file by in-memory. Let it through. A brotli version will be delivered, which is better then in-memory non-compressed.

                        // Problem: after Logout/Login Chrome takes index(Logout version).html from disk-cache, instead of reload.
                        // Because when it is read from 'index.html.br' brottli, it adds etag, and last-modified headers.
                        // So, the index(Logout version).html should NOT be cached, while the index(Login version).html should be cached.
                        // Console.WriteLine($"Adding CacheControl NoCache to header '{httpContext.Request.Host} {httpContext.Request.Path}'");
                        Utils.Logger.Info($"Adding CacheControl NoCache to header '{httpContext.Request.Host} {httpContext.Request.Path}'");
                        httpContext.Response.GetTypedHeaders().CacheControl =
                            new Microsoft.Net.Http.Headers.CacheControlHeaderValue()
                        {
                            NoCache        = true,
                            NoStore        = true,
                            MustRevalidate = true
                        };
                    }
                }
                else if (String.IsNullOrEmpty(ext))                                                                                // 2. API requests
                {
                    if (httpContext.Request.Path.Value?.Equals("/UserAccount/login", StringComparison.OrdinalIgnoreCase) ?? false) // if it is an API call only allow '/UserAccount/login' through.
                    {
                        isAllowedRequest = true;
                    }
                    if (httpContext.Request.Path.Value?.Equals("/WebServer/ping", StringComparison.OrdinalIgnoreCase) ?? false)   // HealthMonitor checks https://sqcore.net/WebServer/ping every 9 minutes, so let's allow it without GoogleAuth
                    {
                        isAllowedRequest = true;
                    }
                    if ((Program.g_webAppGlobals.KestrelEnv?.EnvironmentName == "Development") && (httpContext.Request.Path.Value?.StartsWith("/hub/", StringComparison.OrdinalIgnoreCase) ?? false))
                    {
                        isAllowedRequest = true;    // in Development, when 'ng served'-d with proxy redirection from http://localhost:4202 to https://localhost:5001 , Don't force Google Auth, because
                    }
                    if ((Program.g_webAppGlobals.KestrelEnv?.EnvironmentName == "Development") && (httpContext.Request.Path.Value?.StartsWith("/ws/", StringComparison.OrdinalIgnoreCase) ?? false))
                    {
                        isAllowedRequest = true;
                    }
                }
                else
                {
                    isAllowedRequest = true;    // 3. allow jpeg files and other resources, like favicon.ico
                }
                if ((Program.g_webAppGlobals.KestrelEnv?.EnvironmentName == "Development") && httpContext.Request.Host.Host.StartsWith("127.0.0.1"))
                {
                    isAllowedRequest = true;    // vscode-chrome-debug runs Chrome with --remote-debugging-port=9222. On that Gmail login is not possible. Result "This browser or app may not be secure.". So, don't require user logins if Chrome-Debug is used
                }
                string ipStr = WsUtils.GetRequestIPv6(httpContext, false);
                if (isAllowedRequest)
                {
                    // allow the requests. Let it through to the other handlers in the pipeline.
                    bool isExpectedAllowed = (ipStr == ServerIp.HealthMonitorPublicIp); // Request.Path = "/WebServer/ping". if it comes from a known IP, don't write error message out to console/log.
                    if (!isExpectedAllowed)
                    {
                        string msg = String.Format($"PostAuth.PreProcess: {DateTime.UtcNow.ToString("HH':'mm':'ss.f")}#Uknown user, but we allow request: {httpContext.Request.Method} '{httpContext.Request.Host} {httpContext.Request.Path}' from {ipStr}. Falling through to further Kestrel middleware without redirecting to '/UserAccount/login'.");
                        Console.WriteLine(msg);
                        gLogger.Info(msg);
                    }
                }
                else
                {
                    string msg = String.Format($"PostAuth.PreProcess: {DateTime.UtcNow.ToString("HH':'mm':'ss.f")}#Uknown or not allowed user request: {httpContext.Request.Method} '{httpContext.Request.Host} {httpContext.Request.Path}' from {ipStr}. Redirecting to '/UserAccount/login'.");
                    Console.WriteLine(msg);
                    gLogger.Info(msg);

                    // https://stackoverflow.com/questions/9130422/how-long-do-browsers-cache-http-301s
                    // 302 Found; Redirection; temporarily located on a different URL. Web clients must keep using the original URL.
                    // 301 Moved Permanently: Browsers will cache a 301 redirect with no expiry date.
                    // "The browsers still honor the Cache-Control and Expires headers like with any other response, if they are specified. You could even add Cache-Control: no-cache so it won't be cached permanently."
                    // 301 resulted that https://healthmonitor.sqcore.net/ was permanently redirected to https://healthmonitor.sqcore.net/UserAccount/login
                    // This redirection was fine Before Google authentication, but after that Browser never asked the index.html main page, but always redirected to /UserAccount/login
                    // That resulted a recursion in GoogleAuth, and after 3 recursions, Google realized it and redirected to https://healthmonitor.sqcore.net/signin-google without any ".AspNetCore.Correlation.Google." cookie
                    // And that resulted 'System.Exception: Correlation failed.'

                    // httpContext.Response.Redirect("/UserAccount/login", true);  // forced login. Even for main /index.html
                    httpContext.Response.Redirect("/UserAccount/login", false); //  Temporary redirect response (HTTP 302). Otherwise, browser will cache it forever.
                    // raw Return in Kestrel chain would give client a response header: status: 200 (OK), Data size: 0. Browser will present a blank page. Which is fine now.
                    // httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
                    // await httpContext.Response.WriteAsync("Unauthorized request! Login on the main page with an authorized user."); // text response is quick and doesn't consume too much resource
                    return;
                }
            }
            else
            {
                // if user is accepted, index.html should be rewritten to change 'Login' link to username/logout link
                // in Development, Host = "127.0.0.1"
                if (((Program.g_webAppGlobals.KestrelEnv?.EnvironmentName == "Development") || httpContext.Request.Host.Host.StartsWith("sqcore.net")) &&
                    (httpContext.Request.Path.Value?.Equals("/index.html", StringComparison.OrdinalIgnoreCase) ?? false))
                {
                    //await _next(httpContext);
                    //await context.Response.WriteAsync($"Hello {CultureInfo.CurrentCulture.DisplayName}");
                    //return Content(mainIndexHtmlCached, "text/html");

                    // This solution has some Non-refresh problems after Logout, which happens almost never.
                    // After UserAccount/logout server redirect goes to => Index.html. Reloads, and it comes from the cach (shows userName), which is bad.
                    // (But one simple manual Browser.Refresh() by the user solves it).
                    // >write to the user in a tooltip: "After Logout, Refresh the browser. That is the price of quick page load, when the user is logged in (99% of the time)"
                    // Console.WriteLine($"Adding CacheControl MaxAge to header '{httpContext.Request.Host} {httpContext.Request.Path}'");
                    Utils.Logger.Info($"Adding CacheControl MaxAge to header '{httpContext.Request.Host} {httpContext.Request.Path}'");
                    httpContext.Response.GetTypedHeaders().CacheControl =
                        new Microsoft.Net.Http.Headers.CacheControlHeaderValue()
                    {
                        Public = true,
                        MaxAge = TimeSpan.FromDays(8)
                    };

                    var mainIndexHtmlCachedReplaced = mainIndexHtmlCached[0] + WsUtils.GetRequestUser(httpContext) +
                                                      @"&nbsp; <a href=""/UserAccount/logout"" title=""After Logout, Ctrl-Refresh the browser. That is the price of quick page load, when the user is logged in (99% of the time)"">Logout</a>"
                                                      + mainIndexHtmlCached[2];
                    await httpContext.Response.WriteAsync(mainIndexHtmlCachedReplaced);

                    return;
                }
            }

            await _next(httpContext);
        }