From f7c642cbc5f97c40ea8297abd2f624f851962839 Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Fri, 9 Feb 2024 22:49:03 +1300 Subject: [PATCH] Added CSRF protection for WebsiteHost. #4 --- docs/decisions/0130-front-end-integration.md | 61 +++ .../0090-authentication-authorization.md | 21 +- .../0110-back-end-for-front-end.md | 279 ++++++++++++ .../0900-back-end-for-front-end.md | 1 - docs/images/BEFFE - Reverse Proxy.png | Bin 0 -> 52269 bytes docs/images/Sources.pptx | Bin 729803 -> 740138 bytes .../appsettings.json | 2 +- src/ApiHost1/Api/TestingOnly/TestingWebApi.cs | 16 + src/ApiHost1/ApiHostModule.cs | 1 + src/ApiHost1/appsettings.json | 2 +- .../Services/IHostSettings.cs | 10 + .../appsettings.json | 2 +- .../DomainServices/IEncryptionService.cs | 8 + .../AesEncryptionServiceSpec.cs | 19 +- .../DomainServices/AesEncryptionService.cs | 24 +- .../DomainServices/TenantSettingService.cs | 5 +- .../ApplicationServices/HostSettingsSpec.cs | 23 + .../HostSettings.cs | 28 +- .../TokensServiceSpec.cs | 4 +- .../TokensService.cs | 2 +- .../Infrastructure.Shared.csproj | 4 - .../Extensions/HttpResponseExtensions.cs | 21 + .../HMACSigner.cs | 14 +- .../HttpConstants.cs | 5 + .../BackEndForFrontEnd/LogoutRequest.cs | 2 +- .../GetInsecureTestingOnlyRequest.cs | 10 + .../PostInsecureTestingOnlyRequest.cs | 10 + .../CSRFMiddleware.cs | 21 - .../Infrastructure.Web.Common.csproj | 16 + .../Resources.Designer.cs | 62 +++ src/Infrastructure.Web.Common/Resources.resx | 26 ++ ...ucture.Web.Hosting.Common.UnitTests.csproj | 1 + .../MiddlewareTestingAssertions.cs | 87 ++++ .../Pipeline/CSRFMiddlewareSpec.cs | 422 ++++++++++++++++++ .../Pipeline/CSRFServiceSpec.cs | 133 ++++++ .../Pipeline/CSRFTokenPairSpec.cs | 246 ++++++++++ .../Pipeline}/ReverseProxyMiddlewareSpec.cs | 3 +- .../Extensions/RequestExtensions.cs | 60 +++ .../Extensions/WebApplicationExtensions.cs | 10 +- .../Infrastructure.Web.Hosting.Common.csproj | 3 + .../Pipeline/CSRFConstants.cs | 24 + .../Pipeline/CSRFMiddleware.cs | 232 ++++++++++ .../Pipeline/CSRFService.cs | 41 ++ .../Pipeline/CSRFTokenPair.cs | 141 ++++++ .../Pipeline}/MultiTenancyMiddleware.cs | 2 +- .../Pipeline}/ReverseProxyMiddleware.cs | 3 +- .../Resources.Designer.cs | 99 ++++ .../Resources.resx | 33 ++ .../AuthNApiSpec.cs | 39 +- .../CSRFSpec.cs | 312 +++++++++++++ ...ucture.Web.Website.IntegrationTests.csproj | 1 + .../RecordingApiSpec.cs | 42 +- .../ReverseProxyApiSpec.cs | 10 +- .../WebsiteTestingExtensions.cs | 56 ++- .../Api/AuthN/AuthenticationApiSpec.cs | 135 ++++++ .../AuthenticationApplicationSpec.cs | 76 +--- .../appsettings.Testing.json | 2 +- .../IHttpClient.cs | 24 + .../TestingClient.cs | 117 +++++ .../WebApiSpec.cs | 31 +- src/SaaStack.sln | 7 +- src/SaaStack.sln.DotSettings | 7 + src/UnitTesting.Common/OptionalAssertions.cs | 20 + .../Api/AuthN/AuthenticationApi.cs | 71 ++- .../Application/AuthenticationApplication.cs | 70 +-- .../Application/IAuthenticationApplication.cs | 3 +- src/WebsiteHost/BackEndForFrontEndModule.cs | 27 +- src/WebsiteHost/Controllers/CSRFController.cs | 50 +++ .../Controllers/Home/HomeController.cs | 90 ++++ src/WebsiteHost/Resources.Designer.cs | 9 + src/WebsiteHost/Resources.resx | 3 + src/WebsiteHost/appsettings.json | 6 +- src/WebsiteHost/wwwroot/index.html | 7 +- 73 files changed, 3202 insertions(+), 252 deletions(-) create mode 100644 docs/decisions/0130-front-end-integration.md create mode 100644 docs/design-principles/0110-back-end-for-front-end.md delete mode 100644 docs/design-principles/0900-back-end-for-front-end.md create mode 100644 docs/images/BEFFE - Reverse Proxy.png create mode 100644 src/Domain.Services.Shared/DomainServices/IEncryptionService.cs rename src/Infrastructure.Shared.UnitTests/{ApplicationServices => DomainServices}/TokensServiceSpec.cs (96%) rename src/Infrastructure.Shared/{ApplicationServices => DomainServices}/TokensService.cs (97%) create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/GetInsecureTestingOnlyRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/PostInsecureTestingOnlyRequest.cs delete mode 100644 src/Infrastructure.Web.Common/CSRFMiddleware.cs create mode 100644 src/Infrastructure.Web.Common/Resources.Designer.cs create mode 100644 src/Infrastructure.Web.Common/Resources.resx create mode 100644 src/Infrastructure.Web.Hosting.Common.UnitTests/MiddlewareTestingAssertions.cs create mode 100644 src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFMiddlewareSpec.cs create mode 100644 src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFServiceSpec.cs create mode 100644 src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFTokenPairSpec.cs rename src/{Infrastructure.Web.Common.UnitTests => Infrastructure.Web.Hosting.Common.UnitTests/Pipeline}/ReverseProxyMiddlewareSpec.cs (96%) create mode 100644 src/Infrastructure.Web.Hosting.Common/Extensions/RequestExtensions.cs create mode 100644 src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFConstants.cs create mode 100644 src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFMiddleware.cs create mode 100644 src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFService.cs create mode 100644 src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFTokenPair.cs rename src/{Infrastructure.Web.Common => Infrastructure.Web.Hosting.Common/Pipeline}/MultiTenancyMiddleware.cs (91%) rename src/{Infrastructure.Web.Common => Infrastructure.Web.Hosting.Common/Pipeline}/ReverseProxyMiddleware.cs (98%) create mode 100644 src/Infrastructure.Web.Website.IntegrationTests/CSRFSpec.cs create mode 100644 src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticationApiSpec.cs create mode 100644 src/IntegrationTesting.WebApi.Common/IHttpClient.cs create mode 100644 src/IntegrationTesting.WebApi.Common/TestingClient.cs create mode 100644 src/WebsiteHost/Controllers/CSRFController.cs create mode 100644 src/WebsiteHost/Controllers/Home/HomeController.cs diff --git a/docs/decisions/0130-front-end-integration.md b/docs/decisions/0130-front-end-integration.md new file mode 100644 index 00000000..6c0cab4d --- /dev/null +++ b/docs/decisions/0130-front-end-integration.md @@ -0,0 +1,61 @@ +# Front-End Integration + +* status: accepted +* date: 2024-02-10 +* deciders: jezzsantos + +# Context and Problem Statement + +In SaaStack, we are demonstrating a template for a typical secure SaaS web product. + +Most SaaS products today start out as only web applications (to achieve a single deployed version of a product). But later, there are always reasons to offer an API platform for integration into other products. Thus, SaaStack is demonstrating both from day one. In some SaaS businesses, other kinds of applications are also pursued, for example, Mobile apps, IoT devices, Desktop apps, integrations to marketplaces. In all these cases, a central Backend API is desirable at all stages of the business. + +Backend APIs (typically REST APIs) are designed for two things: + +1. To model complex business workflows (as opposed to what is more common to most developers, just modelling a relational database) +2. To be consumed by human developers building integrations to 3rd party systems, or other applications. + +Frontend applications are designed for one thing: + +1. To provide an optimized human or machine interface that interacts with a core product (i.e., a Backend API) + +Every Frontend application has a discrete set of users (human or machine) and a discrete subset of use cases to expose. + +Every Frontend application requires it to be optimized for usability on a specific platform. (viz: the difference between a web app and a mobile app). + +No Backend API (collectively) can be optimal for each and every Frontend that integrates with it, but it can provide a superset of use cases for most Frontend applications that integrate with it. + +Thus it becomes necessary for a more optimized translation between what data and performance can be provided by a Backend and what is required by any specific Frontend. + +That translation can be done in one of 3 places: + +1. In the centralized Backend, exact to each remote Frontend, defined by the remote Frontend on demand (i.e., multiple data and performance profiles in the same place, that change whenever a Frontend is added or changed) +2. In the remote FrontEnd. +3. In a dedicated "nearby" intermediary between the centralized Backend and remote Frontend. (i.e., may change whenever a specific Frontend changes) + +> When it comes to the web and remote distributed systems, capacity, performance, and reliability are not guarantees. See the [Fallacies of Distributed Computing](https://en.wikipedia.org/wiki/Fallacies_of_distributed_computing). Thus, any communication between components in a distributed system is going to compromise the performance and reliability of any remote system. + +## Considered Options + +The options are: + +1. One centralized Backend, several "nearby" dedicated BEFFE + one remote Frontend +2. One centralized Backend, several remote Frontends (i.e., no intermediaries) +3. One remote Frontend, one Dedicated centralized Backend (i.e., no standalone API) + +## Decision Outcome + +`BEFFE` + +- Every application type has its unique needs that a dedicated BEFFE can accommodate on-demand, without changing any Backend. +- A BEFFE is always deployed "nearby" to the Backend where they don't pay the network performance compromises that remote Frontends incur +- BEFFE can cache, aggregate, and filter data for a specific use-case in a Frontend, that would otherwise require too much data or latency if delivering from a Backend. +- A BEFFE can cache, aggregate and filter data from one or more Backend API or 3rd party systems to save on chatty communications with so many systems, and deliver just what the Frontend desires, how it desires it, in its most optimal form. +- Authentication/Authorization can be performed appropriately for each kind of Frontend by the BEFFE, rather than incurring that complexity in a Backend. Separation of concerns. For example, using secure cookies for untrusted Frontends (e.g. JavaScript apps), and tokens for trusted machine Backends. + +## (Optional) More Information + +See these strong arguments for distributed systems: + +* https://learn.microsoft.com/en-us/azure/architecture/patterns/backends-for-frontends +* https://samnewman.io/patterns/architectural/bff/ diff --git a/docs/design-principles/0090-authentication-authorization.md b/docs/design-principles/0090-authentication-authorization.md index 386dd494..05153b86 100644 --- a/docs/design-principles/0090-authentication-authorization.md +++ b/docs/design-principles/0090-authentication-authorization.md @@ -21,7 +21,7 @@ For Authorization, we are utilizing the minimal API "authorization policies" mec In web clients, we will use (HTTPOnly) cookies to store JWT tokens (between the browser and the BEFFE), and will relay those JWTs to backend APIs via a reverse proxy. -​ We will prevent those JWT tokens ever being seen by any JavaScript running in a browser, and go to extra lengths to guard against CSRF attacks. +We will prevent those JWT tokens ever being seen by any JavaScript running in a browser, and go to extra lengths to guard against CSRF attacks. ![Credential Authentication](../images/Authentication-Credentials.png) @@ -181,11 +181,6 @@ When the system is split into individual services each containing one or more su API keys do not support refreshing issued API keys. When issuing the API key the client gets to define the expiry date, and should acquire a new API key before that expiry date themselves. -### Cookie Authorization - -* TBD -* Performed by a BackendForFrontend (BEFFE) component, reverse-proxies the token hidden in the cookie, into a token passed to the backend - ### Declarative Authorization Syntax Authorization is both declarative (at the API layer), and enforced programmatically downstream in other layers. @@ -237,8 +232,18 @@ Just like the roles above, there are two sets of "features" that apply separatel All `End-Users` should have, at least, a minimum level of access to all untenanted API's based on a specific feature set, otherwise they literally have no access to do anything in the system. By default, every end-user in the system should have the `PlatformFeatures.Basic` feature set, used for accessing all untenanted APIs, and some Tenanted APIs, no matter what subscription plan they have. -In most SaaS products there are one or more pricing tiers. These are analog to "features". +In most SaaS products, there are one or more pricing tiers. These are analog to "features". It is likely that every product will define its own custom tiers and features as a result. -By default, we've defined `Basic` to represent a free set of features, that every user should have at a bear minimum. This "feature set" needs to be made available even when the end-user loses their access to the rest of the system. For example, their free-trial expired. We've also defined `PaidTrial` to be used for a free-trial notion, and other tiers for paid pricing tiers. These are expected to be renamed for each product. \ No newline at end of file +By default, we've defined `Basic` to represent a free set of features that every user should have at a bare minimum. This "feature set" needs to be made available even when the end-user loses their access to the rest of the system. For example, their free-trial expired. We've also defined `PaidTrial` to be used for a free-trial notion, and other tiers for paid pricing tiers. These are expected to be renamed for each product. + +### Web Application Authentication/Authorization + +Web applications are typically implemented in a browser using JavaScript. A browser application (or JS app) operates in a very hostile environment, where the running code is more open to attack than many other kinds of applications. + +In this kind of environment, web browsers must collaborate with web applications to provide stateless and secure environments to run. Unlike other platforms, standard measures like encrypted storage of secrets are not 100% achievable, and stateless are harder to achieve. + +There are few reliable measures that can be utilized, like Http-Only cookies, and even then, additional measures need to be taken (i.e., CSRF measures) to ensure that those are not compromised either. + +See [BEFFE](0110-back-end-for-front-end.md) for more details on how Authentication and Authorization are implemented in the web application. \ No newline at end of file diff --git a/docs/design-principles/0110-back-end-for-front-end.md b/docs/design-principles/0110-back-end-for-front-end.md new file mode 100644 index 00000000..cd68271b --- /dev/null +++ b/docs/design-principles/0110-back-end-for-front-end.md @@ -0,0 +1,279 @@ +# Back-End for Front-End + +(a.k.a BEFFE or BFF) + +A web BEFFE is a web server is designed specifically to serve a web application (i.e. a JavaScript application like those created with a framework like ReactJs/AngularJs/VueJs application). + +* The BEFFE is typically (but not always) the same web server that serves up the JS app that runs in the browser. +* It is always the web server that the JS app sends requests to for HTML and JSON. +* Sometimes, a BEFFE exposes its own JSON APIs; other times, it forwards those JSON requests to other Backend APIs. + +A BEFFE is a de-coupling design strategy to avoid designing a Backend API that is directly coupled to a specific Frontend, or a Backend API that has to be all things to several Frontend applications (e.g., a web application, an admin we application, a mobile application and a desktop application), all of which have different needs for data processing. + +> This frees the designer of the Backend to focus on designing REST APIs instead of RPC/CRUD APIs to only serve a single Frontend. + +## Design Principles + +* A BEFFE is a dedicated boundary to a specific type of client application. Typically, a web application. Its purpose is to serve assets and data to a web application just the way the web application likes it, and to abstract the visual interface from the machine interface of the Backend APIs. +* A BEFFE should remain stateless +* A BEFFE can offer caching of responses to make the UI more responsive and typically aggregates data from various backend sources (e.g., different APIs). +* A BEFFE is a necessary strategy when the same development team designs both the Frontend and the Backend of a system at the same time. Without this, it is easy for the team to forget that both HTTP interfaces (HTML for Frontend and JSON for Backend) are designed for two quite different [human] audiences. A Frontend is a visual [human] interface designed for the end-users of the product, focused on performing familiar tasks easily. The Backend is a machine interface designed around business processes for [human] developers who then integrate other systems with the API of the product (regardless of what subsequent interface they are building and for whom). Thus these two interfaces are necessarily designed quite differently. Without a BEFFE, a team is likely to fall back to creating RPC/CRUD-like APIs, which does not achieve much more than making an API directly to their database. Then that simplification leads them to distribute core logic between various layers of the overall system (if they have any bounded layers at all), and that lack of structure and lack of encapsulation creates a big-ball-of-mud which slows them down in later stages, as the product onboards more [incidental complexity](https://en.wikipedia.org/wiki/No_Silver_Bullet). +* A BEFFE typically provides a secure way to maintain stateful (but not sticky) user sessions. Typically using [HTTPOnly, Secure] cookies to avoid the need for clients to store any secrets or tokens in the browser (which is always to be avoided since XSS vulnerabilities are ever-present). The BEFFE can then store and forward tokens created by Backends when calls are forwarded from BEFFE to Backend APIs. This avoids the tendency that many developers have to project cookies or sessions (or other legacy web application authentication mechanisms) onto their Backend API, which makes them far harder for machine integration. +* A BEFFE may provide its own API tailored to the JS app, or it may provide a reverse proxy to forward requests on to other Backend APIs. + +## Implementation + +The `WebsiteHost` project is a BEFFE. It performs these tasks: + +1. It serves up the JS app (as static files). +2. It serves up other HTML, CSS, JS assets (as dynamic assets). +3. It implements a Reverse Proxy in order to forward JSON requests to Backend APIs +4. It implements its own Authentication API endpoints so that [HTTPOnly, secure] cookies can be used between the BEFFE and JS app to avoid storing any secrets in the browser. +5. It necessarily protects against CSRF attacks (since it uses cookies). +6. It provides API endpoints for common services, like: Recording (diagnostics, usages, crash reports, etc), Feature Flags, etc. +7. It provides a platform to add dedicated API endpoints tailored for the JS app that can be easily added as required. +8. It provides the opportunity to deploy (and scale) the web application separately from backend APIs. + +### Reverse Proxy + +![Reverse Proxy](../images/BEFFE - Reverse Proxy.png) + +BEFFEs are intended to be built specifically for a specific Web Application. + +> BEFFEs can also be built for Mobile applications + +They are responsible for returning data to client applications (i.e., the JS app) in the most efficient form. Thus they are tightly coupled to the JS app. To do this well, they often filter and aggregate data fetched from other Backend sources (via HTTP service clients). + +> In general, BEFFE do not directly access databases for application data. They use Backend APIs for that data. They may implement their own caches, and stores to enhance performance. + +Strictly speaking, the APIs that they serve are dedicated APIs (to the JS app), and strictly speaking, that would require implementing APIs in the BEFFE as well as implementing APIs in a respective Backend. In many cases, this can be extra work with little economic value in the long run. Therefore, the BEFFE offers a simple Reverse Proxy so that API calls can simply be forwarded to the Backend, and no additional BEFFE API needs to be implemented. + +In other cases, specifically around some queries and some user workflows, dedicated APIs in the BEFFE are a more economical option (as they can aggregate, filter, and cache many sources of data). + +Another performance consideration is that the network latency between the BEFFE and Backend API is typically orders of magnitude faster and more reliable than the network latency and reliability between the Browser and the BEFFE (especially when the BEFFE and Backend API are deployed in the same cloud datacenters). + +#### How the Reverse Proxy Works + +The BEFFE handles ALL inbound requests from the JS app. + +> All requests from the JS app should go to the BEFFE directly, no calls from the JS app should go to any Backend API - ever. + +Depending on the route (and the request type), the inbound call is either routed to: + +1. An MVC controller (avoiding the Reverse Proxy), or +2. An in-built API Endpoint (if one has been implemented in the BEFFE - as a minimal API), or +3. Forwarded by the Reverse Proxy to a Backend API. + +### Authentication + +Since there is a Reverse Proxy handling inbound calls, and since we do not want to allow the JS app to handle any session secrets (i.e., JWT access/refresh tokens), we must also provide a way to manage the user's session using [HTTPOnly, secure] cookies, as the only secure means to keep these secrets from the browser. + +> There are so many security challenges when JavaScript clients handle *access_tokens* and *refresh_tokens*, particularly if those tokens are not protected sufficiently from disclosure in the browser to JavaScript vulnerabilities. +> +> Even when protected diligently, there are always future chances of XSS exploits in the JS app (e.g., security exploits discovered later in 3rd party libraries), and when a future XSS exploit is successful, there is no longer any effective way to protect disclosure to any of those tokens in the JS app. +> +> Assuming that any JS app is fully protected against XSS, and the secrets that it holds cannot be disclosed, is a very bold assumption to make these days. Because XSS is so difficult to fully protect against, and once it is compromised, all secrets are revealed, the only really secure means to protect *access_tokens* and +*refresh_tokens* from disclosure in the JS app is NOT to have them present (or accessible) to the JS app at all. + +The BEFFE offers a dedicated API for authentication (`POST /api/auth`) that intermediates the authentication process. Regardless of what data is used to authenticate the user (i.e., password credentials or SSO tokens, etc.), the call is made directly with the BEFFE and then relayed to the various authentication endpoints in the backend. + +If the attempt to authenticate is successful, the authentication response from the Backend API (e.g., from `POST /passwords/auth` or `POST /sso/auth`) will always include an `access_token` and a `refresh_token`. + +These "auth tokens" are then added to [HTTPOnly, secure] cookies that are then returned to the browser to be used between the browser and BEFFE for subsequent requests/responses. + +![Authentication](../images/Authentication-Credentials.png) + +At some point in time, either of those auth tokens will expire, at which point either the `access_token` can be refreshed (using the `refresh_token`), or the `refresh_tokesn` expires, and the end user will need to re-authenticate again. + +#### Login + +To authenticate (login) a user from the browser, make a XHR call to `POST /api/auth` with a body containing either, the user's credentials, + +For example, + +```json +{ + "Username": "auser@company.com", + "Password": "1Password!", + "Provider": "credentials" +} +``` + +or with a body containing an SSO authentication code, + +For example, + +```json +{ + "AuthCode": "anauthocde", + "Provider": "sso" +} +``` + +> Note: you will also need to include CSRF protection in these requests, like all others coming from a JS app. + +A successful response from this request will yield the following body, + +For example, + +```json +{ + "UserId" : "user_auserid" +} +``` + +But it will also include these cookies (for the current domain): + +`auth-tok=anaccesstoken` + +`auth-reftok=arefreshtoken` + +#### Logout + +To logout explicitly, call `POST /api/auth/logout` with an empty JSON body: + +> Note: you will also need to include CSRF protection in these requests, like all others coming from a JS app. + +A successful logout request will remove both the `auth-tok` and `auth-reftok` cookies (from the current domain). + +#### Refreshing Session + +When the `access_token` cookie (`auth-tok`) expires (by default, after 15 mins), it will be necessary to refresh the `access_token`. + +To refresh the session, call `POST /api/auth/refresh` + +> Note: you will also need to include CSRF protection in these requests, like all others coming from a JS app. + +A successful token "refresh" should renew both the `auth-tok` and `auth-reftok` cookies (from the current domain). + +However, If the current `refresh_token` has expired, or there is no refresh token cookie (`auth-reftok`) to begin with, then a `HTTP 401 - Unauthorized` will be returned. Signaling that the user is no longer authenticated. + +### CSRF Protection + +Since there are several cookies exchanged between the browser and BEFFE, there must be [CSRF protection to avoid forgery exploits](https://owasp.org/www-community/attacks/csrf). + +> Note, if there were no cookies then there would be no need for CSRF protection. +> +> Note: No cookies should ever be exchanged between the BEFFE and the Backend APIs. + + + +Implementing CSRF protection correctly requires that the JS app adheres to the strict CSRF implementation policy: + +1. All XHR calls (using any method) must go directly to the BEFFE, which must also be the origin server of the JS app that is making the request. No requests (of any sort) from the JS app should ever bypass BEFFE and go directly to any Backend API. +2. This policy applies to all "un-safe" API methods: `POST`, `PUT`, `PATCH`, and `DELETE`, but it excludes `GET`, `HEAD`, and `OPTIONS`. + +3. Any "un-safe" XHR call (from JS app to the BEFFE) that changes any state must be implemented with the methods: `POST`, `PUT`, `PATCH`, `DELETE`, and not `GET`, `HEAD`, or `OPTIONS`. +4. Include in all "unsafe" XHR calls (e.g., `POST`, `PUT`, `PATCH`, `DELETE`), an HTTP header called `anti-csrf-tok`. The value of this header can be read from the `meta` header called `csrf-token` found in the header of the HTML of `index.html`. For example, + + ``` + var csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); + ``` +5. The JS app must not store the value of the `csrf-token` anywhere in the browser. Neither in-memory, nor in local storage, nor any other cache or store in the JS app. It must read the value from the HTML of `index.html` each and every time it needs it since the value will change every time `index.html` is requested from the BEFFE. + +6. Ensure that this `csrf-token` value is never included in any "safe" XHR call (e.g., `GET`, `HEAD`, or `OPTIONS`), and never exposed in any browser history. + +7. After the user is authenticated, or after the authenticated user logs out, a new value of both the `anti-csrf-tok` cookie and metadata header `csrf-token` will be updated by the BEFFE. The JS app must implement a scheme that re-fetches the `index.html` page containing these new values for subsequent XHR calls. Failing to do so, will block all subsequent XHR calls to the BEFFE. + +#### How it works + +Every fetch of `index.html` will change the value of the `anti-csrf-tok` cookie (same domain), and change the related `csrf-token` value in the `meta` header in the HTML. These two values are paired and work together to mitigate CSRF forced-browser attacks. They are also specific to the authenticated user, if any, at the time. Thus, these paired values change value after the anonymous user changes to be authenticated (i.e., login) and after when the authenticated user changes to be anonymous (i.e., logout). + +> All failures due to CSRF violations are reported as a `403 - Forbidden`. + +The CSRF cookie (`anti-csrf-tok`) has a value that expires every 14 days (by default). When this expires, any subsequent XHR request is likely to fail and continue to fail until the `index.html` page is re-fetched. + +> One way to deal with this issue is to re-fetch `index.html` and thus, renew the token and cookie. + + + +In the BEFFE, we are using a defense-in-depth strategy to mitigate against CSRF (informed by the [OWASP guidance](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)). These are the 3 mechanisms that are used together: + +1. Double Submit Cookie (per session) +2. Verifying Origin +3. CORS + +##### Workflow + +- Each request to `index.html` creates a CSRF token and writes it into the `csrf-token` metadata tag of the `index.html` page. +- The value is unique for each fetch of `index.html` regardless of whether the user is authenticated or not. +- A corresponding `anti-csrf-tok` cookie is also updated with the HMAC signature of the CSRF token. +- The JS app is then required to send back (in any "unsafe" XHR call to the BEFFE) the value of the `csrf-token` metadata value in a request header called `anti-csrf-tok`. +- The two values sent to the BEFFE are paired, but not the same, and they are compared (via HMAC signatures) in the BEFFE to prove that they are paired. +- The CSRF token corresponds to the currently authenticated user, but it is encrypted. +- In addition, the `origin` header of the request (or the `referer` header of the request) is compared to ensure the request originated from JavaScript served from this BEFFE. Otherwise `HTTP 403 - Forbidden` is returned. +- A [same-origin CORS policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) is also enabled. + +##### Defeating CSRF + +To defeat the CSRF protection, an attack would have to send an "un-safe" state-changing request to the BEFFE, that: + +1. Included in the request is a signed `anti-csrf-tok` cookie, signed with the same HMAC key stored on the BEFFE. +2. Included in the request is an `anti-csrf-tok` header containing a CSRF token (paired to the cookie above), encrypted with the same AES encryption key stored on the BEFFE. +3. That CSRF token (above) would have had to include the ID of the currently authenticated user (a.k.a session ID). Or include no user ID for an anonymous call. +4. Included an `origin` header (or included a `referer` header) that matched the origin of the JS app. +5. By-passed the browsers CORS pre-flight check. + +For an attacker to do this from JavaScript running in the same browser, they need to defeat these challenges: + +* Point 1 (above): The current cookies (including `anti-csrf-tok`) are automatically sent to the BEFFE (by the browser) when making a cross-origin request from www.hacker.com to www.yourproduct.com. In a compliant browser, JavaScript should not be allowed to create (read or write) its own HTTP-Only cookies. Even if they could do this, they would have to know the same HMAC secret as the BEFFE to create a valid signature for that cookie value. +* Point 2: The JS on www.hacker.com would have to download a request to www.yourproduct.com/index.html and read the HTML to retrieve a value for the `csrf-token` from the HTML. This is forbidden by the [Same Origin Policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy) (SOP) in compliant browsers. The JS on www.hacker.com would have to write the `anti-csrf-tok` header on the request with the value it reads from the `index.html` page. Writing custom headers on requests (like: `anti-csrf-tok`) is forbidden by SOP in compliant browsers. +* Point 3: The JS on www.hacker.com would have to know the AES encryption key on the BEFFE to create their own token value. (They could, however, use their own encrypted token and related cookie- from monitoring their own account on the site - to force a re-login of their account and initiate a "Login CSRF"). +* Point 4: The JS on www.hacker.com would have to spoof the `origin` or `referer` headers on the request. The browser should not allow JavaScript to do this. +* Point 5: The JS on www.hacker.com would have to bypass the CORS preflight check set on www.yourproduct.com in order for any state-changing API call to succeed. + +All of these things above would have to happen in JS on www.hacker.com to bypass the CSRF protection on any state-changing XHR request. + +> For these reasons, only compliant browsers should be used to access the JS app and BEFFE + +##### Testing CSRF + +Finally, it is worth noting that in automated testing, specifically in the API integration tests that test the CSRF protection, we actually fabricate both the cookie and header values (using the same code that the BEFFE does to generate them), as opposed to downloading those values from a live version of `index.html` from the BEFFE. + +At present, the BEFFE is not keeping track of past generated values against requested values, so this means that as long as we are using the same Signing and Encryption secrets as the BEFFE is using, at testing time, we can correctly test the CSRF protection mechanism. + +> In the future, we may decide to track incoming CSRF tokens and signature values against an index of previously issued tokens/signatures by the BEFFE, to ensure that they have not been fabricated outside the browser. If that happens, the testing would have to adjust to fetching values from a request to `index.html` each time. + +### API calls from JavaScript + +All API calls will be proxied through the Reverse Proxy in the BEFFE (to the Backend API). + +After the user is authenticated, those proxied calls will include the JWT `access_token` in an `Authorization` header, extracted from the `auth-tok` cookie. + +It is possible that subsequent calls to the Backend API will eventually respond with `HTTP 401 - Unauthorized` response, once the token has expired (or been revoked). This response will get proxied back to the JS app. + +The JS app now has a choice to make on how to handle the `HTTP 401 - Unauthorized` response: + +1. It can handle that response and direct the user to the login page for them to authenticate again, and redirect them back to where they started. +2. It can attempt to refresh the `access_token`, obtain refresh `access_token`, and then retry the call to the Backend. + +> Clearly, due to the short lifetimes of access_tokens (being in the order of 15 mins), users would experience poor usability if they were asked to log in on the same frequency that an `access_token` expired, especially in most SaaS-based products. + + + +The following process should be implemented in the JS app, to maintain a reasonable usable experience for most users: + +1. In a global handler, for each XHR call, handle the case when an `HTTP 401 - Unauthorized` is received. + - Otherwise, process the request as a normal response. +2. When `HTTP 401 - Unauthorized` is received, attempt to refresh the users authenti"session" by calling `POST /api/auth/refresh`. + - If this refresh call fails with `HTTP 401 - Unauthorized` (expired or not exists to begin with) then redirect the user to a login page to re-authenticate. + - If this refresh call succeeds with `HTTP 200 - OK` then retry the original XHR request (as this request should now be proxied with a valid and refreshed `access_token` by the BEFFE) + - If the retried request fails a second time with `HTTP 401 - Unauthorized` then redirect the user to a login page to re-authenticate. This could indicate that the `refresh_token` has now expired, or the `access_token` has been revoked or is, at this point in time, invalid for some other reason. + +> Note: responses that include `HTTP 403 - Forbidden` are likely to be from CSRF violations which are also applicable to all these XHR interactions. + +### Ancillary Services + +The BEFFE also implements a number of general-purpose ancillary API endpoints for use by the JS app. These include: + +* Recording - recording crashes in the JS app, recording usage data, etc +* Feature Flags - for controlling the behavior of feature sin the JS app +* Health check - for checking the responsiveness of the BEFFE and its deployed version +* Other ancillary APIs. + +### Custom APIs + +The BEFFE has been designed to be extended to include additional (domain specific) endpoints to support the JS app, when those calls special handling, such as caching, aggregation and filtering of data from Backend APIs. + +These endpoints can simply be added in the same way API endpoints are added to any host. The Reverse Proxy will detect requests to these endpoints and route them directly to the BEFFE API to process first. + diff --git a/docs/design-principles/0900-back-end-for-front-end.md b/docs/design-principles/0900-back-end-for-front-end.md deleted file mode 100644 index 2fd9f957..00000000 --- a/docs/design-principles/0900-back-end-for-front-end.md +++ /dev/null @@ -1 +0,0 @@ -TBD \ No newline at end of file diff --git a/docs/images/BEFFE - Reverse Proxy.png b/docs/images/BEFFE - Reverse Proxy.png new file mode 100644 index 0000000000000000000000000000000000000000..844a0acccc4ef388d048849a6a8aaa53606e02cc GIT binary patch literal 52269 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 KCGWEBqyGUp;K!W+ literal 0 HcmV?d00001 diff --git a/docs/images/Sources.pptx b/docs/images/Sources.pptx index 0b22fa0109bfd409c439a02e8583b561f7e3e5b4..9e7a652d1049ba9a2d64b8684ceb962a54c1ffe6 100644 GIT binary patch delta 22368 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 delta 12729 zcmZ8{1zZ(P`!?qs@*G;arKLNhTT+njR*{xQ;84_Y>3knC>mNA zRV4)z6glWJ%}1zojB!d3TK8Ou)A?IL#cQRn<)_$5zhM*(T-AB^(hIWw>`M5Hx#>=( zYu=inSXi>iPR0@>5o+)+_pL#9eo12dnsrGGnmia9Ef9;GMbesgC0FW^yikW2H$!h( zYR8V?vU5kJUT z?Z_=hPG)X-Z{TPTKjey(Ux|gY^uIIizM^C$wYQ<|5~1jH-1`9G=iyW|(aEdVdgoH0%}%FxKcC2fDH}euZen)A3Yt>b4CG493Hs6VvoNHd|74xrMI*>0jyg3bdkHk*G=L^G(8ROn zqLE#Gn4o=|)34)kv4j0RIc_hkJ3@gr`HB(4e=wb~1b^KUuDfE#414A-00`RQhQ$V1ojI zANCO)^${IHz6~$e2$b}MprJM6qIR&Lybz?0<3~Qi;CZYw0+wCJj1v9GF@XP%L-uXewry*op?i)S#Ub>G`bF~ct0X`0EHixq!U+Ch>Y6o6L-69L&@HBqU@yIo^ zw}~>gG_~YKyg^bm%&5*}ZjQ?$H+h)1s;RL$`9r?bu=lXn@#|?Dtg@QQ9oZ8$Q)d(8 zdcb3jx&B23gZ6LC{q>aJtp=+zzBGP%Wefye4dsPhjeehYHg6B(kv7%d86cYx@xp1% z6(X&FP1`xYC3G}K(T0CpS&@WMiNDAWdlWMbcT|Z({=t^xAAgal`_}ir7V^d1Nshs# zjeYTi3q3}TMIl$D&n}lofFlNHgAphCi!)^fd1S%;Jb}<9dx{Vo@DQ5wge4MJ(w?gSB zQre2~9u+$eHS*k(pnyN=_5Um|>C)#x24&1M>h@zY8xwfx`zAJq?4%^OX1z#FwpG`K zmM@Mqal5s->WOmg&*F&nN7{$2z%U+E9@i0lRCgWiIk_y)3>=`rhnk4tVQgTYdszNN z5B=>bM?&$TqpHuBrKGI6zohD!!CkLIEnjoep+Vw?ZPSVtTLqpARR1t#YY6#OD#J7> za3u-F1wO!`RKRC(=)?{Ej1S#MeP)IR}G96lbIG8g2jx2H)kqWlqF&w=xMP)Fp}!G)FY@#Tcbuv}l| zPE#%Q>yvV(#;Ok+JKo!ummzswUdr7~F`|KN-*;5wcFqpyk-nm(J?|P93WGk-7`>r3 z4A|azL1mCFnlABWf3K8Y5$khZi&I0Wso$o43No~Ix~H?1-jDl(gikrZTfB>!ZY1%u z+%f%9>_Bvfsyr8YYu-tRoY{~y1Onhj@8%uDHE^U75jwLOs zYK%#Hfo7bC-RrC2{oXDe@nsS&`uydcjaM^I4JA{zC-ItHks@P7?^d!uc93Zer@56= zTf~^p>&f5EVE*>CyU5Aonem6&&v8GTbofFkJ`wH-hE|1g5p`NWSa_*JqwLdoH_G5G zi85fxvH5|)Ki|O=sG>hByUTgzEfRT1(MOqNtTp@VQO21P@fkWP*8pLxaVS)tJn9P} zuR}+GMr_k7#2Ruk0rR=2LXLPvMqHp$2@yPSB6gN+jy;!$7aEnRD%rLj%U*p1jeD=MxQUb?V5#bk?xV(1r_NqfOIDaQqdZz@tz&IYf81<;o~}%QNRr|;*^5V6M^{0JvU#Ia@MFUa zN;DCjpluw<#ESim3mf*r_H`~xRD6t3+!q_20x?_n^qwEn2z<+j3tPrRd(^Lm`cqE* z2p_Rq@W2!W)O6BA)?+2RRZ}8-la-IzhKg9WbDIDq;{x zx3aiQm0z+_4nRK=_5=KC#_04>O82mbQeH$nKN==fOe&lUSr}{<{GAg{b2@e2ihk*o z19O$l#)y_jzDjzW?y%pdfHL_L-$P)_gk0_%>v)MS0!uE&!JB%iyP*)%<8n1V3?$!$ zlEJrYi}4#U(9nFz0f;J;5h=#w>+JN&$lP^Wgd~u@;)=odbLk0-4m^Y8`#2wiW*AH0 zWG3U6N)h9)%#&UQBy00iL0ZUuo;&=Nw4&bM>;_}KxasLE_GxC-PYyH3nk&+)91cn( z>5>h7?fM1Es+yan=~~+2k|nC9cDehH{6d%(#W@31+jtVWtPn3H*$FYWq!qo*ik~ews#*?nl`H_H$(8=DKEsgGcf=z-AGK#QQS|zAUibQus zS+5IXKg=eaA*SNMR4dYE*JIn&P`D|1KeMFl-R%3C3<6AkX_zVwJ+r=XkX_y`U!*eo z3cnoJFE;jt+e&$w@*VDd0YD&?2-;H-!ynj{EIFMpIXY86)x(yaZ`dY8ulbU zmt`*%T6h2!*$1R89-90!`tN8Ao=4W;X(fRp8e!vE1twY^ieMV%Jr-sn)iStPcM3Vm9AFz3b^Z}K?J)?X|AT$YpE8n2dK4WuPu+Yhj83k8 z><*^9nz>2`^7>p0H*cZwjdZBn`&HcVm~A&5x+fYDBbD7uN+Cx|e>pzv9 zmw9XIdXjr-2UxLj>2jk^h~VN0Nka?gYR(BKNFCGa9SeJwmQzdaveT^d4ro!^<3x?v zQp+-I#RW`=lSdcjRxNgtI;FA>O;fq;x5uG-YzIg~1xzaM6SJO^!~Eo3vaEEsPLq-6 zW!`fb&ou^2vAo$vX>FmVnkQLr7$OUImlO6(&GZKbHxsHYic>$l1C^l)g63v>GuZng$2R)ph?N_$tuev^~&)!d3R|$`c)Rz1q_|dG;UC8>3NTTE11Qv$nCtTV1#hpkbS z+YK+CzInQv+~kz@i~Pxj8+Qgu^}?lCQ(*i;Y6SJf{u7-)mX4$7)ytf7#Pwzg{;1cn z0vnvygNXmm>!`&C&{Y`nhaf_s1ZTDwFTmHorBc;Z^&5NfE@wikcDHx}y?j^_193Ob zQ*&yjj}*#W3v`Emit_R~mhS3BN+wT{<=^lhh!*e2_(w$+?Kl*ByZ9>{UJZ+x8l zEg2auDGParlb^iGC=-$`V=eO5x_wMS-l4Gjcb&J@UeJ_7W@5f$raV%)<2!qfrb)?V zyuyb?v2xL`LXYiM8Os=h@)jd2x8~k$Pb$Y2eEo2x&0DMXGHiWVv0FM&^zP-~ohICH zP9@A69SyCG0BALXGNCw)pbuae5rLgG3_z$UbWPY~g*}J+`H~bL>~p6!pK>~8G)=qO z9QoJ4$DEhx-hk=bWE1mT8{tt^b9`L_-Ga?;iN}*|*ZXOVXLt&Bg+PNDR2rFIl&h6T zO^zr~t?)j!o;Qk4c)n3bvOUEO{f+XLDYUF&W?5b=h=wuCT4=;%P-eaIh{!+Rxu`3T z!&?fR6cPL;UZoi(>}UF7X!+B5Jp)>%GVf#EkrL;LPcxNs0m%JL-rdHfRt~N8a&v@2 z&*M|uyMl)Dp1tW~tDFYkE{l-cMohchrWU%lc~9QzyPYcalyeT&I@^uaIDPt(L%%rB z1K1CL6|o#IB~l<9=HOty4AS?$ASshzHt99y?R>o3l+;z8Iyvw$O6d*rdKV$z+zkRefurnsfwG^_xIpTHZFEF~4m`oqXouY^dP7heInqb3;u?M0v|+5%L*! z1gUeJjmc8{7Y$1hYN^Vj8Q#J>Qgb|hWy;31wi1FY(GrJ_#lkor`y#u~9|~J$$yjB6 zsKP2OTZeovi!3R{bUXg$Ug&uglOOXAl67PgTDJoBhjJA48RZ^Yvf#pGCD!661jPOJ z@Jui>kD}qgv|O+Fql^lqOpK~8exr)F`AYNl^jookX@9*TX6gRwrFcos`uRoXL%ulO zr1}X=q8i;K3?7L4#-EVw-j@?UpOl7oNygtrIBEoxws+l|&#F6FoqKH3|B}&{;n$Rn zm45u}6Ov4AVS)H`;6X~*k@ANQz56aPR}2SYZY?8pV$b`0%&y9@k!C+_0xgp_ZF@Ti zcXcJhcM`fqTw=5KFPT+T%jAUwDfLV7t>0UG>MplA&DWKoA_00x^(C+!+j~!}IJHtr zKFLt$3mfUcmtfl%OA*5+F8l&9^86%*D%P6ra^e>U-WAntDm7UVcjZQl=*O9xKd9=z zh>cls8Z&qvzSyechxAWQ{1zp~z)TTBAqTxj71@5@Eu#zP1=qU|y!Tl`rM%R9r>R#T z6_MB+6D4CpelIx2)aa&6>5Wg%9VoreeKK_9qbPyxadoFrHc`fYc;BFvpQFxt3_>(p z$i!p7A|uHVaF@Yh+^iHoVU3os(oCA-*IU#1OcR;6p|k70e+-Zk(*3XF>>zG@dyqO9 zltFP{AnF!xlmI>P?^9aKKK$Lb0gcDCul)nX2+wOK5B==h*pOeJtNVF>oy1g4T3}AL zAC_cyq-92H8o{KzO~Euj#5moWkzn`yr}hhBrsd^~>Vw+5*4uY9IJ(yyll9_6#-(_) z%$z0@xN0|Ni!^PJzJ4tysdM+HOJ*1A7Mn#&#NrrnlW&Fa$>T7DaLK@};7RdcUoZ#7 zB^{r}n8Ky+?c@kAS9zvxWczkYs(A6L3uhE%8`^ioReN1HB)VP26K4}+oxKca=(NVn z7fV2^F((gqN4H-2^NRlT!~Od6h!?`ok~}UFp8d$ejEv42Kx&epTd$$1SHy$CD%uP24oO|h(tMfM6(E?>~^?XA}OQ@$2{o#OjQuGi-Tf4%MJ=0@V=K%O;}2WjrI zq)Z%S$>76KpNL497+T5Ud&=;@Mj%*_-)7Z*p1sQ6T{GFNYI))oF5jbm?yBBbsZNJE zij(O*!&8V!LiDI1n@S^d%fPYj^f@H5nk(LM%3H}iUgP($2%d?(mxUg{bTA&<1=o0E z>yTDxZ~i6OPhH~REOqztsFl*&9(k!d#Xm)5^=2btz0ZvAJ1$ljkLig0RLgs6la5-y zSsq=?!tG@S*KhOfM^!Xd(?^(tHANa;$g`JcnL&aS1#j&K+OJ%6gtF?s zOZJ$2`-=Z5stILwH{`^A)qXg!TK(h#kwvW(1k~$ZaE|?hzgLQ-!ROU$bDng)t&oTo9kvI zr3F34uO52p0OCJm;%NSOPVpnRXG^%1S9VoHnS3$`IYW|_wxW!_ya+c4&9aKQVD3pc zel;K8Z2kQKWGd2(jedUz(#lm;x3eMs3>Z^S6Tb_AI4bI$x+fx!3HMq1Jh-1x5j$>) z|5|?22UlQ&_jo%c+MXL@zyiLj@W+N^G{HkL-#Th-Zj}g@P{}S)sN*8Xsfdk&oX9Yn zoEa?QdeztaV{Iy;|Ae_=X`vN!x_L^HTl-M^+BNkZ)s~FpP>jTd%Q%aJA2uWmDb} zVtVhIUwj!jJv57Wp4DZaY))A!^4Nk;fs~)fzsSq#`E)uqg0p7e^H-6Jx2{`DGWuxL zMEJCrM6WJp+`i8&{P|rRLXb{n!FSu+W-@!zm_4R9eKR7#-ZuPWB{F_XmAFk(SZreE zcJ{~>LP0y&+*L*?YKgHI2S11L{zIisk3S|eoZ?A5k_vVKa>mK-Zq2XRAT73Mk1GVyE}XZf>2 z`c7&0a44^Mrq>xM_khja#=fXMuV^uXulvMvtZF};v{ofE{ltiJ%7Y7dVkPek?1%7V z%=VEblai(q&(}K7MwX{7PxcJU%BbR|F+H8}(B|{9SP_^!pGZW8iA8Yb5Mw= z8Q5DhT-6LQ9UGQ%nL4P%@3LapMY9BD`2(oF~^pBW#o zj87>n&-j<&eVoWEle-viS$!7UGh;|qxJQGrH1X=`j!4wZDi1b& z@9ewF_&QDVC@C#F&Qo~Q0?mmQ;iC4Q;#1EhZno^HrcVP$g*E&S0qiOM2!>WszJU)` zLQP#}5e>K=NXodcTyIEWc)}yZr7VuLD(W=cw@u`0y2gkN)g`$@78}@OH{hWi1n>Ai z4AQ@qP5i0Ft{467cb!EgL4$lgTk^B`s6WhZlEhG-2(zz2eM*q2vdyR24(4AbcfTk} znSUYN`qC|BuG4e0b{B;BqqUUm@?$)6RN*F4VSIBP8FP^!)fjZ&uwagEHaGFP)jHiW z_rmY5?)1LiOls+_|#t zPyOmj1bRLo2FLtT`!RSV2KC}`w>9IpI28jvBTc=`)y54Uy-)OF@v-wCGr8-hWO~W; z$(9o(fSesC)+JW*#1HK2JMZ1MH0U2Sc{$f0t6RH1KiiPxHE+EJo+GvGEJ?G=*OHxu(H?K~c&v~&muR|~ zq4u%N=433cBC<)`U+dmDwO9-p@ZSsfwF|n~k3_yW`U;5^4F~Q;J~6zj*Yq`SDhJ;t zN;K(N28j^hbuD zBe&(QITm#vZK&T!Vp5w@pZG9*zXHpT(6{$!J04W0Bc*1y28B8^xF0voy$z8VwzDak zoIOd55h~HELwQ;-t6G@G(a)U{$}G?!L+d)0@Y%975i)g%9|NVq-UZM0 zdRWu$?4LozLYrq`R_7V{QEMN8_1VpK78 z;Q1I&<$n4hl6)ty_)T)L-^p#IbZ>IHuyivt$cX3_jcS%iEKVZLO5Q|o*qf()x;rm4 zejl4)p8bb0x?p%7mi$j9*{^&MeuH7V*y-hiSY*l*IcWN_(S|FMdZW!$Nn8V;5h_sx|Opv(VM@N*a6xo3ZZdFwnRj zKhf%fr?yHP?)!ajnJ|eTJvyYDEFNBa$~T+&aCRh!dm5l;M_{1Ry`U*D^q1TWUjZ&Z=n52D3n2A}s>1bIF6ZLGw*YK6Z+B5K{?Nzh zNFqT*r}nKnU6P+}=e09*hh$^5U-w%V|L96(e>-{IVT>s*{rXc@u*XlEh2UHBVfw)) zb%R08i)2;XEXT(jF4k+xV}Z*x2izqRJY&Q64)v1@U%6Lu%m*mfUnt@nGXnn5gh~=D z9vhc%qPluSp~*uP3R5|XdYSBB<@c;V?I4%WIWy~3#N*upIolH=+jaPD=$y=?2vpc{ zS1OWU>V8~3`tER7$pK@M_6c8(^yS%fVd52Shxu>&^EmN@Mi;!PJ;`y_)?b7g!`PMW zUL%rK;xS_OpN_j`3iG~dg-x1ow|92dd>h@Zsw?)mXdyQZ3;51oy4m&4NQ^?lGq;#^ zypP9J={&83zFg@{z{R4h_MTcx>)EN!oAAlV9eKU&C9Iw5E6DXO7=2bEz754245h(9 z66;czyj8Y)D?-}&ZBsMOKej}zsk?FU4?^Fp@5O+bDH>hTYP4+0&q50lsCCl_-t$|} z`vQZyhZIX`XD4Ce4f{G}dQ%Z)})}c%{Ai+&Mz`vpUX8vr{}|q|)6o_SHnsQkrst*CpM? zOVQn>Q#S|22QT3RqQDdGPpz9JvBQDqbgjqqXh6pcC<9Ot2W3SK#6XD=sGe_dAEL;a zG$?8&4OXv>THwUeLr2Zu#kPh6j)Dk6pl}hLAhH9F4*oDu9dOK=$0+we%m!AJ)H3`9 zJ75)w4o7hmU?$1~>T=*WMUx}U2;oQ$J}eXmA9gJqmAwK(X9idl5rmQ2O4#5J2c@k9 z_f9~0d0_72-1;Y0Ba_WqG7J1l{uvk{L{K67uVXb@$opgnn|oi}Ld9bY$^|pD{3L9D zvWu%ah|8X`plxw_NUOHbs3-e6RY=fvuGzTWcV;}oNgr+7Nv<$8j@bTr>XZS-g?a0Y zv^F%^v_HPOBo_-0QoH;xqA~i*YFx-fQATH%pFMw_cisxokT#N^ z@(+HrW9LrQ7S{WQx|+`f<3F1YO%R7$in^TC5-GX3iR^SDQ=Q2}mYpkM&;p0*$Q0bj zKUhti>=ec>8K<*Pol_!tD%~^hnH+F4Q~Tv3)LKitn17M?ie-Lo)BTvami12 zNV>t`27H!X;fpt!5hm8u(s4MUwA9ZK>-wEj1p!c5N@AJFMBwww~D>o^;U#M1&I>kG;!Y>GL(J8;G4_B@LBxn6ve6j>b->YhBdDD z^tv^e4Y}H~IB;&fXSb!QvES8*Vwo(5acub$)v8};a4&AmY2x4ehx{PD)>+_{|r&CJ^e6UY+E#t zd}i^J)X)R@%bF(TtAU~Lka#Bx5xg_&K~L)b%A!t4(zBh=E$v>_lc*fasynK07-BB0 zeyW6V!HsDuF4x}odkZ5)xDtsjeYVcz)>JyVC@R*H@fH!isd7`St(SJ|yw@)&agt#* zn#y2MgD)zD7{LTVPRSCs7;YeqPUzy^kTKSX$ty-FsA}`~`|TxIj~g zBz{laB4Xw%xXUl$f5!jo%)ESptW@Dmq}=E_Ca&ib=8Z!>Bi7W+reJa28j^v>6qwK4 zIs(rR5jm#1!H3uhIyl=A9X)3@gb_TmvOH#yk2N(OI`K$8M|#s{aCMTg(wbk@q}ePUO>M-Ua#j2A_ZNJ>A%^zzm3a5Gq~qqBZKoNyNZNuZw73iGw7r zKSS)+03^+YH~^kMbJQTKK2S#Qbo;PJAhOM2=^4Zn2kUKKrxhm`d^^m<0E^V%`GH6_ z#Wt6a$h24SL>_aIlylX2tc)=l9bvncov+vPhI>0f$f^x~ z+^-gxt9gLoDV(*V@b{6C{zfl#-Z(>IMxvs)aL%R- zvHR$z=ejOlpKbZ;;9ea1D{%6?x`Lr;t3#mNX#Y&i{{+zi)f5OF_#1FnWBJtH$%7Yg zXF`ysu3-`4g3)&W{}{&t@{Az2fCrQa9>_KjLWz)vkO5sFKwE*-S%4g%r$TT*k(sI4 zndAT_Ex5uwr9x0a%z=0+P+AO(fB@C$OLglb2RNw_91v!}fEqN#EZ-DdMvdTwW}p2f zsUv3?z|pm+7Pw7=P=Ndf?5`mdD87bLVEY;x0DfA80_;~61R$)2kOK*{U|Ah__pn<*e?<3?l1gJnjNrAUiU^6UlBdB26#}J@H z2}*t)h64vS!6=&>`|Cgd-!4jzpaRmsf3S=b2r$PCO8e^oUzPFg=14 zn7p>IQv-WU2uc8-0U-$8qXCi`5EOv4AXxTYMljc^5cJ=j-%fWe(10>A+z8!7{v*7; zIB3M}8?(}Fgb*~G@rI;clTfA`Qb-409T*!#1r27tshE!uOfF@)A;@bo!Fof6ugN&u z4WVTM3Dcb$;&e^iIc`Xs?0>Fdj0r3k#d&Skgobk8B#$WkC)tA;OpfKfF&BY^=5J&Y z4D5CA{<{6|jz<&X2O#VSIso?$f|TZOz!4}r!uemBw(yNioduNr6GHL7G7|9{S;n>O zpD%L%E5nt!k*#0L{`vNVhQ|0`*QTp@BNJmqkkS18js!k1IfVZ!!%@AFEto+ufdp0r zAz&Gr|V8=O2INe`R&n*D^txf1H&6Rg^!xDaZxjDPLS;Q-jJQ7|0?CT-;H_vT65}uLrzmbjRY51H(J17OZp2a;^yfB ztHuQkxe)kJtiT)b3KxPJ0JqG9c>fGJeh?Husv=kc2shZI=ZOF#f&o^KU{nAbAE>f;4CwNKDlXS+6(vx`2TqWQPk|-S2!ngW z0Ji{8s>lyYr;vafKUiI4GEjYu-&24i5Mjn;Frcj(tX5k9RJk?*0Rmv^gKt2a0D=~X zkV7!TR2E=BnH+)zSX2P#uH>IE;7S2(Gw32v!S;7xKqVZV49FG)vvup{vu%G>JotWpba|u4%oRy@d|)n6vWjY!1Eeo1_9JHDlY)1Ai{iB&;e;N zge(m0932P&PoQtmfKD+`Axs8fii3(A1`NPL5`zq|5(lqIT@;7|tB|Ml=cOtL4-k~F@W|0Ft@M-n7c9_u$BO=6}cFIYcptV0R}~M%-DT;u`CJ00$t#Z2w>YqEg@$XIx?c{@@8z_!iJC1uCe? zfvamo=Lh7aLBx;-o=YRxfL&>DAi51Pf#5()GGIj$|Bmn5l{W}{?A&@KsuFR#t=ryzf&3z;D8X{f9(p?>46_Bj0%8iF2HO=YJs315T^m~hfadG_ZbblW?$I4=044}Z z06AnBj&4{48)XK=~fn(hNyBuqz0%Xpli$^nluXVCGw% zz#kA{{%_#G=L|6QZ`GyOBO44x3HM7QX!!owm;Il^3@&{{|4c{VS-=YX@8Ehn*!uwY z)xbFcbl9k1_txNmvKoT%+GqQBV+=Ro0EQZZne(q32krtZL7(EkOBC9KquIIIYCm;# z^We30eQFQnQ6Wea|IYydS1oXX``0nC{jUtxZf@7%irVTJ;JSc@Rt^4H^P{2Z?ZH9z F{{W~70+av% diff --git a/src/AWSLambdas.Api.WorkerHost/appsettings.json b/src/AWSLambdas.Api.WorkerHost/appsettings.json index d89e8f3e..f8799526 100644 --- a/src/AWSLambdas.Api.WorkerHost/appsettings.json +++ b/src/AWSLambdas.Api.WorkerHost/appsettings.json @@ -9,7 +9,7 @@ "Hosts": { "AncillaryApi": { "BaseUrl": "https://localhost:5001", - "HmacAuthNSecret": "asecret" + "HMACAuthNSecret": "asecret" }, "WebsiteHost": { "BaseUrl": "https://localhost:5101" diff --git a/src/ApiHost1/Api/TestingOnly/TestingWebApi.cs b/src/ApiHost1/Api/TestingOnly/TestingWebApi.cs index 81dd7c0f..b9aacce7 100644 --- a/src/ApiHost1/Api/TestingOnly/TestingWebApi.cs +++ b/src/ApiHost1/Api/TestingOnly/TestingWebApi.cs @@ -119,6 +119,22 @@ public async Task> FormatsRoun }); } + public async Task GetInsecure( + GetInsecureTestingOnlyRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + + return () => new Result(); + } + + public async Task PostInsecure( + PostInsecureTestingOnlyRequest request, CancellationToken cancellationToken) + { + await Task.CompletedTask; + + return () => new Result(); + } + public async Task> RequestCorrelationGet( RequestCorrelationsTestingOnlyRequest request, CancellationToken cancellationToken) { diff --git a/src/ApiHost1/ApiHostModule.cs b/src/ApiHost1/ApiHostModule.cs index 791561bf..6a479495 100644 --- a/src/ApiHost1/ApiHostModule.cs +++ b/src/ApiHost1/ApiHostModule.cs @@ -9,6 +9,7 @@ using Infrastructure.Persistence.Interfaces; using Infrastructure.Persistence.Shared.ApplicationServices; using Infrastructure.Shared.ApplicationServices; +using Infrastructure.Shared.DomainServices; using Infrastructure.Web.Hosting.Common; namespace ApiHost1; diff --git a/src/ApiHost1/appsettings.json b/src/ApiHost1/appsettings.json index 1c91ccfa..aface782 100644 --- a/src/ApiHost1/appsettings.json +++ b/src/ApiHost1/appsettings.json @@ -28,7 +28,7 @@ "Hosts": { "AncillaryApi": { "BaseUrl": "https://localhost:5001", - "HmacAuthNSecret": "asecret" + "HMACAuthNSecret": "asecret" }, "IdentityApi": { "BaseUrl": "https://localhost:5001", diff --git a/src/Application.Interfaces/Services/IHostSettings.cs b/src/Application.Interfaces/Services/IHostSettings.cs index ffd6574c..c998b5ac 100644 --- a/src/Application.Interfaces/Services/IHostSettings.cs +++ b/src/Application.Interfaces/Services/IHostSettings.cs @@ -25,4 +25,14 @@ public interface IHostSettings /// Returns the URL of the Website host /// string GetWebsiteHostBaseUrl(); + + /// + /// Returns the CSRF encryption secret + /// + string GetWebsiteHostCSRFEncryptionSecret(); + + /// + /// Returns the CSRF signature secret + /// + string GetWebsiteHostCSRFSigningSecret(); } \ No newline at end of file diff --git a/src/AzureFunctions.Api.WorkerHost/appsettings.json b/src/AzureFunctions.Api.WorkerHost/appsettings.json index d552c5e0..b10bda40 100644 --- a/src/AzureFunctions.Api.WorkerHost/appsettings.json +++ b/src/AzureFunctions.Api.WorkerHost/appsettings.json @@ -9,7 +9,7 @@ "Hosts": { "AncillaryApi": { "BaseUrl": "https://localhost:5001", - "HmacAuthNSecret": "asecret" + "HMACAuthNSecret": "asecret" }, "WebsiteHost": { "BaseUrl": "https://localhost:5101" diff --git a/src/Domain.Services.Shared/DomainServices/IEncryptionService.cs b/src/Domain.Services.Shared/DomainServices/IEncryptionService.cs new file mode 100644 index 00000000..5084d4b9 --- /dev/null +++ b/src/Domain.Services.Shared/DomainServices/IEncryptionService.cs @@ -0,0 +1,8 @@ +namespace Domain.Services.Shared.DomainServices; + +public interface IEncryptionService +{ + string Encrypt(string plainText); + + string Decrypt(string encryptedValue); +} \ No newline at end of file diff --git a/src/Infrastructure.Common.UnitTests/DomainServices/AesEncryptionServiceSpec.cs b/src/Infrastructure.Common.UnitTests/DomainServices/AesEncryptionServiceSpec.cs index 2f1e3e18..68b9a162 100644 --- a/src/Infrastructure.Common.UnitTests/DomainServices/AesEncryptionServiceSpec.cs +++ b/src/Infrastructure.Common.UnitTests/DomainServices/AesEncryptionServiceSpec.cs @@ -1,4 +1,3 @@ -#if TESTINGONLY using FluentAssertions; using Infrastructure.Common.DomainServices; using Xunit; @@ -8,13 +7,24 @@ namespace Infrastructure.Common.UnitTests.DomainServices; [Trait("Category", "Unit")] public class AesEncryptionServiceSpec { + private readonly string _secret; private readonly AesEncryptionService _service; public AesEncryptionServiceSpec() { - var secret = AesEncryptionService.CreateAesSecret(); +#if TESTINGONLY + _secret = AesEncryptionService.CreateAesSecret(); +#else + _secret = string.Empty; +#endif + + _service = new AesEncryptionService(_secret); + } - _service = new AesEncryptionService(secret); + [Fact] + public void WhenCreateAesSecret_ThenReturnsSecret() + { + _secret.Should().NotBeEmpty(); } [Fact] @@ -27,5 +37,4 @@ public void WhenDecryptAndEncrypted_ThenReturnsPlainText() plainText.Should().NotBe(cipherText); plainText.Should().Be("avalue"); } -} -#endif \ No newline at end of file +} \ No newline at end of file diff --git a/src/Infrastructure.Common/DomainServices/AesEncryptionService.cs b/src/Infrastructure.Common/DomainServices/AesEncryptionService.cs index 81876244..f1395f9d 100644 --- a/src/Infrastructure.Common/DomainServices/AesEncryptionService.cs +++ b/src/Infrastructure.Common/DomainServices/AesEncryptionService.cs @@ -1,11 +1,12 @@ using System.Security.Cryptography; +using Domain.Services.Shared.DomainServices; namespace Infrastructure.Common.DomainServices; /// /// Provides a domain service for encrypting values, using AES encryption /// -public class AesEncryptionService +public class AesEncryptionService : IEncryptionService { private const string SecretKeyDelimiter = "::"; private readonly string _aesSecret; @@ -18,9 +19,8 @@ public AesEncryptionService(string aesSecret) public string Decrypt(string cipherText) { using var aes = CreateAes(); - var (cryptKey, iv) = GetAesKeysFromSecret(_aesSecret); - using var decryptor = aes.CreateDecryptor(cryptKey, iv); - + var (key, iv) = GetAesKeysFromSecret(_aesSecret); + using var decryptor = aes.CreateDecryptor(key, iv); var cipher = Convert.FromBase64String(cipherText); using var ms = new MemoryStream(cipher); using var cryptoStream = new CryptoStream(ms, decryptor, CryptoStreamMode.Read); @@ -31,8 +31,8 @@ public string Decrypt(string cipherText) public string Encrypt(string plainText) { using var aes = CreateAes(); - var (cryptKey, iv) = GetAesKeysFromSecret(_aesSecret); - using var encryptor = aes.CreateEncryptor(cryptKey, iv); + var (key, iv) = GetAesKeysFromSecret(_aesSecret); + using var encryptor = aes.CreateEncryptor(key, iv); byte[] cipher; using (var ms = new MemoryStream()) @@ -56,10 +56,10 @@ private static (byte[] key, byte[] iv) GetAesKeysFromSecret(string aesSecret) var rightSide = aesSecret.Substring(0, aesSecret.IndexOf(SecretKeyDelimiter, StringComparison.Ordinal)); var leftSide = aesSecret.Substring(aesSecret.IndexOf(SecretKeyDelimiter, StringComparison.Ordinal) + SecretKeyDelimiter.Length); - var cryptKey = Convert.FromBase64String(rightSide); + var key = Convert.FromBase64String(rightSide); var iv = Convert.FromBase64String(leftSide); - return (cryptKey, iv); + return (key, iv); } private static SymmetricAlgorithm CreateAes() @@ -75,14 +75,14 @@ private static SymmetricAlgorithm CreateAes() #if TESTINGONLY public static string CreateAesSecret() { - CreateKeyAndIv(out var cryptKey, out var iv); - return $"{Convert.ToBase64String(cryptKey)}{SecretKeyDelimiter}{Convert.ToBase64String(iv)}"; + CreateKeyAndIv(out var key, out var iv); + return $"{Convert.ToBase64String(key)}{SecretKeyDelimiter}{Convert.ToBase64String(iv)}"; } - private static void CreateKeyAndIv(out byte[] cryptKey, out byte[] iv) + private static void CreateKeyAndIv(out byte[] key, out byte[] iv) { using var aes = CreateAes(); - cryptKey = aes.Key; + key = aes.Key; iv = aes.IV; } #endif diff --git a/src/Infrastructure.Common/DomainServices/TenantSettingService.cs b/src/Infrastructure.Common/DomainServices/TenantSettingService.cs index f76d3621..7be59647 100644 --- a/src/Infrastructure.Common/DomainServices/TenantSettingService.cs +++ b/src/Infrastructure.Common/DomainServices/TenantSettingService.cs @@ -1,4 +1,5 @@ using Domain.Interfaces.Services; +using Domain.Services.Shared.DomainServices; namespace Infrastructure.Common.DomainServices; @@ -9,9 +10,9 @@ public class TenantSettingService : ITenantSettingService { public const string EncryptionServiceSecretSettingName = "DomainServices:TenantSettingService:AesSecret"; - private readonly AesEncryptionService _encryptionService; + private readonly IEncryptionService _encryptionService; - public TenantSettingService(AesEncryptionService encryptionService) + public TenantSettingService(IEncryptionService encryptionService) { _encryptionService = encryptionService; } diff --git a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/HostSettingsSpec.cs b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/HostSettingsSpec.cs index 02018e51..e4797a6f 100644 --- a/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/HostSettingsSpec.cs +++ b/src/Infrastructure.Hosting.Common.UnitTests/ApplicationServices/HostSettingsSpec.cs @@ -60,4 +60,27 @@ public void WhenGetApiHost1BaseUrl_ThenReturnsBaseUrl() result.Should().Be("http://localhost/api"); } + + [Fact] + public void WhenGetWebsiteHostCSRFSigningSecret_ThenReturnsBaseUrl() + { + _settings.Setup(s => s.Platform.GetString(HostSettings.WebsiteHostCSRFSigningSettingName, It.IsAny())) + .Returns("asecret"); + + var result = _service.GetWebsiteHostCSRFSigningSecret(); + + result.Should().Be("asecret"); + } + + [Fact] + public void WhenGetWebsiteHostCSRFEncryptionSecret_ThenReturnsBaseUrl() + { + _settings.Setup( + s => s.Platform.GetString(HostSettings.WebsiteHostCSRFEncryptionSettingName, It.IsAny())) + .Returns("asecret"); + + var result = _service.GetWebsiteHostCSRFEncryptionSecret(); + + result.Should().Be("asecret"); + } } \ No newline at end of file diff --git a/src/Infrastructure.Hosting.Common/HostSettings.cs b/src/Infrastructure.Hosting.Common/HostSettings.cs index b1076ba9..5c9d1a47 100644 --- a/src/Infrastructure.Hosting.Common/HostSettings.cs +++ b/src/Infrastructure.Hosting.Common/HostSettings.cs @@ -9,10 +9,12 @@ namespace Infrastructure.Hosting.Common; /// public class HostSettings : IHostSettings { - internal const string AncillaryApiHmacSecretSettingName = "Hosts:AncillaryApi:HmacAuthNSecret"; + internal const string AncillaryApiHmacSecretSettingName = "Hosts:AncillaryApi:HMACAuthNSecret"; internal const string AncillaryApiHostBaseUrlSettingName = "Hosts:AncillaryApi:BaseUrl"; internal const string AnyApiBaseUrlSettingName = "Hosts:AnyApi:BaseUrl"; internal const string WebsiteHostBaseUrlSettingName = "Hosts:WebsiteHost:BaseUrl"; + internal const string WebsiteHostCSRFEncryptionSettingName = "Hosts:WebsiteHost:CSRFAESSecret"; + internal const string WebsiteHostCSRFSigningSettingName = "Hosts:WebsiteHost:CSRFHMACSecret"; private readonly IConfigurationSettings _settings; @@ -45,6 +47,18 @@ public string GetWebsiteHostBaseUrl() Resources.HostSettings_MissingSetting.Format(WebsiteHostBaseUrlSettingName)); } + public string GetWebsiteHostCSRFSigningSecret() + { + var secret = _settings.Platform.GetString(WebsiteHostCSRFSigningSettingName); + if (secret.HasValue()) + { + return secret; + } + + throw new InvalidOperationException( + Resources.HostSettings_MissingSetting.Format(WebsiteHostCSRFSigningSettingName)); + } + public string GetApiHost1BaseUrl() { var baseUrl = _settings.Platform.GetString(AnyApiBaseUrlSettingName); @@ -68,4 +82,16 @@ public string GetAncillaryApiHostHmacAuthSecret() throw new InvalidOperationException( Resources.HostSettings_MissingSetting.Format(AncillaryApiHmacSecretSettingName)); } + + public string GetWebsiteHostCSRFEncryptionSecret() + { + var secret = _settings.Platform.GetString(WebsiteHostCSRFEncryptionSettingName); + if (secret.HasValue()) + { + return secret; + } + + throw new InvalidOperationException( + Resources.HostSettings_MissingSetting.Format(WebsiteHostCSRFEncryptionSettingName)); + } } \ No newline at end of file diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/TokensServiceSpec.cs b/src/Infrastructure.Shared.UnitTests/DomainServices/TokensServiceSpec.cs similarity index 96% rename from src/Infrastructure.Shared.UnitTests/ApplicationServices/TokensServiceSpec.cs rename to src/Infrastructure.Shared.UnitTests/DomainServices/TokensServiceSpec.cs index f9aeb83d..92f79c05 100644 --- a/src/Infrastructure.Shared.UnitTests/ApplicationServices/TokensServiceSpec.cs +++ b/src/Infrastructure.Shared.UnitTests/DomainServices/TokensServiceSpec.cs @@ -1,10 +1,10 @@ using Domain.Interfaces.Validations; using FluentAssertions; -using Infrastructure.Shared.ApplicationServices; +using Infrastructure.Shared.DomainServices; using UnitTesting.Common; using Xunit; -namespace Infrastructure.Shared.UnitTests.ApplicationServices; +namespace Infrastructure.Shared.UnitTests.DomainServices; [Trait("Category", "Unit")] public class TokensServiceSpec diff --git a/src/Infrastructure.Shared/ApplicationServices/TokensService.cs b/src/Infrastructure.Shared/DomainServices/TokensService.cs similarity index 97% rename from src/Infrastructure.Shared/ApplicationServices/TokensService.cs rename to src/Infrastructure.Shared/DomainServices/TokensService.cs index b7917a21..070f3ce6 100644 --- a/src/Infrastructure.Shared/ApplicationServices/TokensService.cs +++ b/src/Infrastructure.Shared/DomainServices/TokensService.cs @@ -4,7 +4,7 @@ using Domain.Services.Shared.DomainServices; using Domain.Shared; -namespace Infrastructure.Shared.ApplicationServices; +namespace Infrastructure.Shared.DomainServices; public sealed class TokensService : ITokensService { diff --git a/src/Infrastructure.Shared/Infrastructure.Shared.csproj b/src/Infrastructure.Shared/Infrastructure.Shared.csproj index c61bb82a..b0446aab 100644 --- a/src/Infrastructure.Shared/Infrastructure.Shared.csproj +++ b/src/Infrastructure.Shared/Infrastructure.Shared.csproj @@ -25,10 +25,6 @@ - - - - diff --git a/src/Infrastructure.Web.Api.Common/Extensions/HttpResponseExtensions.cs b/src/Infrastructure.Web.Api.Common/Extensions/HttpResponseExtensions.cs index 616d287a..04044943 100644 --- a/src/Infrastructure.Web.Api.Common/Extensions/HttpResponseExtensions.cs +++ b/src/Infrastructure.Web.Api.Common/Extensions/HttpResponseExtensions.cs @@ -1,9 +1,30 @@ +using System.Net.Http.Json; +using System.Text.Json; using Application.Common; +using Common.Extensions; +using Microsoft.AspNetCore.Mvc; namespace Infrastructure.Web.Api.Common.Extensions; public static class HttpResponseExtensions { + /// + /// Returns the from the specified + /// + public static async Task AsProblemAsync(this HttpResponseMessage response, + JsonSerializerOptions jsonOptions) + { + var contentType = response.Content.Headers.ContentType; + if (contentType.Exists() + && contentType.MediaType == HttpContentTypes.JsonProblem) + { + return await response.Content.ReadFromJsonAsync(jsonOptions, + CancellationToken.None); + } + + return null; + } + /// /// Extracts the header from the response, /// or creates a new one diff --git a/src/Infrastructure.Web.Api.Common/HMACSigner.cs b/src/Infrastructure.Web.Api.Common/HMACSigner.cs index ccb7bf9e..ccd59d2c 100644 --- a/src/Infrastructure.Web.Api.Common/HMACSigner.cs +++ b/src/Infrastructure.Web.Api.Common/HMACSigner.cs @@ -12,22 +12,26 @@ namespace Infrastructure.Web.Api.Common; public class HMACSigner { private const string SignatureFormat = @"sha1={0}"; - internal static readonly Encoding SignatureEncoding = Encoding.UTF8; + private static readonly Encoding SignatureEncoding = Encoding.UTF8; private readonly byte[] _data; private readonly string _secret; public HMACSigner(IWebRequest request, string secret) : this(GetRequestData(request), secret) { ArgumentNullException.ThrowIfNull(request); - ArgumentException.ThrowIfNullOrEmpty(secret); } - public HMACSigner(byte[] request, string secret) + public HMACSigner(string text, string secret) : this(SignatureEncoding.GetBytes(text), secret) { - ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(text); + } + + public HMACSigner(byte[] data, string secret) + { + ArgumentNullException.ThrowIfNull(data); ArgumentException.ThrowIfNullOrEmpty(secret); - _data = request; + _data = data; _secret = secret; } diff --git a/src/Infrastructure.Web.Api.Common/HttpConstants.cs b/src/Infrastructure.Web.Api.Common/HttpConstants.cs index 31b8756b..a0b4e841 100644 --- a/src/Infrastructure.Web.Api.Common/HttpConstants.cs +++ b/src/Infrastructure.Web.Api.Common/HttpConstants.cs @@ -15,6 +15,7 @@ public static class HttpContentTypes public const string Xml = "application/xml"; public const string XmlProblem = "application/problem+xml"; public const string XmlWithCharset = "application/xml; charset=utf-8"; + public const string Html = "text/html"; } /// @@ -27,6 +28,10 @@ public static class HttpHeaders public const string ContentType = "Content-Type"; public const string HMACSignature = "X-Hub-Signature"; public const string RequestId = "Request-ID"; + public const string AntiCSRF = "anti-csrf-tok"; + public const string Origin = "Origin"; + public const string Referer = "Referer"; + public const string SetCookie = "Set-Cookie"; } /// diff --git a/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/LogoutRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/LogoutRequest.cs index a67ddead..f568dcf9 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/LogoutRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/BackEndForFrontEnd/LogoutRequest.cs @@ -2,7 +2,7 @@ namespace Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; -[Route("/logout", ServiceOperation.Post)] +[Route("/auth/logout", ServiceOperation.Post)] public class LogoutRequest : UnTenantedEmptyRequest { } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/GetInsecureTestingOnlyRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/GetInsecureTestingOnlyRequest.cs new file mode 100644 index 00000000..e14636f1 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/GetInsecureTestingOnlyRequest.cs @@ -0,0 +1,10 @@ +#if TESTINGONLY +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.TestingOnly; + +[Route("/testingonly/security/none", ServiceOperation.Get, AccessType.Anonymous, true)] +public class GetInsecureTestingOnlyRequest : UnTenantedEmptyRequest +{ +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/PostInsecureTestingOnlyRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/PostInsecureTestingOnlyRequest.cs new file mode 100644 index 00000000..5ef02d98 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/TestingOnly/PostInsecureTestingOnlyRequest.cs @@ -0,0 +1,10 @@ +#if TESTINGONLY +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.TestingOnly; + +[Route("/testingonly/security/none", ServiceOperation.Post, AccessType.Anonymous, true)] +public class PostInsecureTestingOnlyRequest : UnTenantedEmptyRequest +{ +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.Web.Common/CSRFMiddleware.cs b/src/Infrastructure.Web.Common/CSRFMiddleware.cs deleted file mode 100644 index 8867940a..00000000 --- a/src/Infrastructure.Web.Common/CSRFMiddleware.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace Infrastructure.Web.Common; - -/// -/// Provides middleware to ensure that incoming requests have not been spoofed via CSRF attacks in the browser. -/// -public sealed class CSRFMiddleware -{ - private readonly RequestDelegate _next; - - public CSRFMiddleware(RequestDelegate next) - { - _next = next; - } - - public async Task InvokeAsync(HttpContext context) - { - await _next(context); //Continue down the pipeline - } -} \ No newline at end of file diff --git a/src/Infrastructure.Web.Common/Infrastructure.Web.Common.csproj b/src/Infrastructure.Web.Common/Infrastructure.Web.Common.csproj index c10c0bfb..5c50ec9b 100644 --- a/src/Infrastructure.Web.Common/Infrastructure.Web.Common.csproj +++ b/src/Infrastructure.Web.Common/Infrastructure.Web.Common.csproj @@ -7,6 +7,7 @@ + @@ -21,4 +22,19 @@ + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + True + True + Resources.resx + + + diff --git a/src/Infrastructure.Web.Common/Resources.Designer.cs b/src/Infrastructure.Web.Common/Resources.Designer.cs new file mode 100644 index 00000000..1be03247 --- /dev/null +++ b/src/Infrastructure.Web.Common/Resources.Designer.cs @@ -0,0 +1,62 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Infrastructure.Web.Common { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Infrastructure.Web.Common.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/src/Infrastructure.Web.Common/Resources.resx b/src/Infrastructure.Web.Common/Resources.resx new file mode 100644 index 00000000..acbd2520 --- /dev/null +++ b/src/Infrastructure.Web.Common/Resources.resx @@ -0,0 +1,26 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/Infrastructure.Web.Hosting.Common.UnitTests.csproj b/src/Infrastructure.Web.Hosting.Common.UnitTests/Infrastructure.Web.Hosting.Common.UnitTests.csproj index d2f65a37..25026f0f 100644 --- a/src/Infrastructure.Web.Hosting.Common.UnitTests/Infrastructure.Web.Hosting.Common.UnitTests.csproj +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Infrastructure.Web.Hosting.Common.UnitTests.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/MiddlewareTestingAssertions.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/MiddlewareTestingAssertions.cs new file mode 100644 index 00000000..42ef35a0 --- /dev/null +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/MiddlewareTestingAssertions.cs @@ -0,0 +1,87 @@ +using System.Net; +using System.Text; +using Common.Extensions; +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Primitives; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Infrastructure.Web.Hosting.Common.UnitTests; + +internal static class ResultExtensions +{ + public static MiddlewareTestingAssertions Should(this HttpResponse instance) + { + return new MiddlewareTestingAssertions(instance); + } +} + +internal class MiddlewareTestingAssertions : ObjectAssertions +{ + public MiddlewareTestingAssertions(HttpResponse instance) : base(instance) + { + } + + protected override string Identifier => "response"; + + public AndConstraint BeAProblem(HttpStatusCode status, string? detail, + string? message = null, string because = "", params object[] becauseArgs) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject) + .ForCondition(response => response.StatusCode == (int)status) + .FailWith("Expected {context:response} to have status code {0}{reason}, but found {1}.", + _ => status, response => response.StatusCode); + + if (detail.HasValue()) + { + ProblemDetails? problemDetails = null; + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject) + .ForCondition(response => + { + problemDetails = GetProblemDetails(response); + + if (problemDetails.NotExists()) + { + return false; + } + + return problemDetails.Detail == detail; + }) + .FailWith( + "Expected {context:response} to be a ProblemDetails containing Detail {0}{reason}, but found {1}.", + _ => detail, _ => problemDetails.Exists() + ? problemDetails.Detail + : "a different kind of response"); + } + + return new AndConstraint(this); + } + + public AndConstraint NotBeAProblem(string? message = null, string because = "", + params object[] becauseArgs) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject) + .ForCondition(response => response.StatusCode == 200) + .FailWith("Expected {context:response} to have status code 200{reason}, but found {0}.", + response => response.StatusCode); + + return new AndConstraint(this); + } + + private static ProblemDetails? GetProblemDetails(HttpResponse response) + { + response.Body.Seek(0, SeekOrigin.Begin); + var body = response.Body.ReadFully(); + + var problem = Encoding.UTF8.GetString(body).FromJson(); + + return problem; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFMiddlewareSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFMiddlewareSpec.cs new file mode 100644 index 00000000..43e4eec6 --- /dev/null +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFMiddlewareSpec.cs @@ -0,0 +1,422 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Security.Claims; +using Application.Interfaces.Services; +using Common; +using Common.Extensions; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common; +using Infrastructure.Web.Hosting.Common.Pipeline; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Moq; +using Xunit; + +namespace Infrastructure.Web.Hosting.Common.UnitTests.Pipeline; + +[Trait("Category", "Unit")] +public class CSRFMiddlewareSpec +{ + private readonly Mock _csrfService; + private readonly Mock _hostSettings; + private readonly CSRFMiddleware _middleware; + private readonly Mock _next; + private readonly ServiceProvider _serviceProvider; + + public CSRFMiddlewareSpec() + { + var recorder = new Mock(); + _hostSettings = new Mock(); + _hostSettings.Setup(s => s.GetWebsiteHostCSRFEncryptionSecret()) + .Returns("anexcryptionsecret"); + _hostSettings.Setup(s => s.GetWebsiteHostCSRFSigningSecret()) + .Returns("asigningsecret"); + _hostSettings.Setup(s => s.GetWebsiteHostBaseUrl()) + .Returns("https://localhost"); + _next = new Mock(); + _csrfService = new Mock(); + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(new LoggerFactory()); + _serviceProvider = serviceCollection.BuildServiceProvider(); + + _middleware = new CSRFMiddleware(_next.Object, recorder.Object, _hostSettings.Object, _csrfService.Object); + } + + [Fact] + public async Task WhenInvokeAsyncAndIsIgnoredMethod_ThenContinuesPipeline() + { + var context = new DefaultHttpContext + { + Request = { Method = HttpMethods.Get } + }; + + await _middleware.InvokeAsync(context); + + _next.Verify(n => n.Invoke(context)); + _csrfService.Verify( + cs => cs.VerifyTokens(It.IsAny>(), It.IsAny>(), + It.IsAny>()), Times.Never); + } + + [Fact] + public async Task WhenInvokeAsyncAndMissingHostName_ThenReturnsError() + { + var context = SetupContext(); + _hostSettings.Setup(s => s.GetWebsiteHostBaseUrl()).Returns("notauri"); + + await _middleware.InvokeAsync(context); + + context.Response.Should().BeAProblem(HttpStatusCode.InternalServerError, + Resources.CSRFMiddleware_InvalidHostName.Format("notauri")); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenInvokeAsyncAndMissingCookie_ThenReturnsError() + { + var context = SetupContext(); + + await _middleware.InvokeAsync(context); + + context.Response.Should().BeAProblem(HttpStatusCode.Forbidden, + Resources.CSRFMiddleware_MissingCSRFCookieValue); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenInvokeAsyncAndMissingHeader_ThenReturnsError() + { + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" } }); + + await _middleware.InvokeAsync(context); + + context.Response.Should().BeAProblem(HttpStatusCode.Forbidden, + Resources.CSRFMiddleware_MissingCSRFHeaderValue); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenInvokeAsyncAndAuthTokenIsInvalid_ThenReturnsError() + { + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { + { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" }, + { AuthenticationConstants.Cookies.Token, "notavalidtoken" } + }); + context.Request.Headers.Add(CSRFConstants.Headers.AntiCSRF, new StringValues("ananticsrfheader")); + + await _middleware.InvokeAsync(context); + + context.Response.Should().BeAProblem(HttpStatusCode.Forbidden, + Resources.CSRFMiddleware_InvalidToken); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenInvokeAsyncAndTokenNotContainUserIdClaim_ThenReturnsError() + { + var tokenWithoutUserClaim = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + claims: new Claim[] { } + )); + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { + { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" }, + { AuthenticationConstants.Cookies.Token, tokenWithoutUserClaim } + }); + context.Request.Headers.Add(CSRFConstants.Headers.AntiCSRF, new StringValues("ananticsrfheader")); + + await _middleware.InvokeAsync(context); + + context.Response.Should().BeAProblem(HttpStatusCode.Forbidden, + Resources.CSRFMiddleware_InvalidToken); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenInvokeAsyncAndTokensNotVerifiedForNoUser_ThenReturnsError() + { + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { + { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" }, + { AuthenticationConstants.Cookies.Token, string.Empty } + }); + context.Request.Headers.Add(CSRFConstants.Headers.AntiCSRF, new StringValues("ananticsrfheader")); + _csrfService.Setup(crs => crs.VerifyTokens(It.IsAny>(), It.IsAny>(), + It.IsAny>())) + .Returns(false); + + await _middleware.InvokeAsync(context); + + context.Response.Should().BeAProblem(HttpStatusCode.Forbidden, + Resources.CSRFMiddleware_InvalidSignature.Format(nameof(Optional.None))); + _csrfService.Setup(crs => crs.VerifyTokens("ananticsrfheader", "ananticsrfcookie", Optional.None)) + .Returns(false); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenInvokeAsyncAndTokensNotVerifiedForUser_ThenReturnsError() + { + var tokenForUser = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + claims: new Claim[] + { + new(AuthenticationConstants.Claims.ForId, "auserid") + } + )); + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { + { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" }, + { AuthenticationConstants.Cookies.Token, tokenForUser } + }); + context.Request.Headers.Add(CSRFConstants.Headers.AntiCSRF, new StringValues("ananticsrfheader")); + _csrfService.Setup(crs => crs.VerifyTokens(It.IsAny>(), It.IsAny>(), + It.IsAny>())) + .Returns(false); + + await _middleware.InvokeAsync(context); + + context.Response.Should().BeAProblem(HttpStatusCode.Forbidden, + Resources.CSRFMiddleware_InvalidSignature.Format("auserid")); + _csrfService.Setup(crs => crs.VerifyTokens("ananticsrfheader", "ananticsrfcookie", "auserid")) + .Returns(false); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenInvokeAsyncAndTokensIsVerifiedButNoOriginAndNoReferer_ThenReturnsError() + { + var tokenForUser = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + claims: new Claim[] + { + new(AuthenticationConstants.Claims.ForId, "auserid") + } + )); + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { + { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" }, + { AuthenticationConstants.Cookies.Token, tokenForUser } + }); + context.Request.Headers.Add(CSRFConstants.Headers.AntiCSRF, new StringValues("ananticsrfheader")); + context.Request.Headers.Add(HttpHeaders.Origin, new StringValues(string.Empty)); + context.Request.Headers.Add(HttpHeaders.Referer, new StringValues(string.Empty)); + _csrfService.Setup(crs => crs.VerifyTokens(It.IsAny>(), It.IsAny>(), + It.IsAny>())) + .Returns(true); + + await _middleware.InvokeAsync(context); + + context.Response.Should().BeAProblem(HttpStatusCode.Forbidden, + Resources.CSRFMiddleware_MissingOriginAndReferer); + _csrfService.Setup(crs => crs.VerifyTokens("ananticsrfheader", "ananticsrfcookie", "auserid")) + .Returns(false); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenInvokeAsyncAndTokensIsVerifiedButOriginNotHost_ThenReturnsError() + { + var tokenForUser = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + claims: new Claim[] + { + new(AuthenticationConstants.Claims.ForId, "auserid") + } + )); + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { + { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" }, + { AuthenticationConstants.Cookies.Token, tokenForUser } + }); + context.Request.Headers.Add(CSRFConstants.Headers.AntiCSRF, new StringValues("ananticsrfheader")); + context.Request.Headers.Add(HttpHeaders.Origin, new StringValues("anotherhostname")); + context.Request.Headers.Add(HttpHeaders.Referer, new StringValues(string.Empty)); + _csrfService.Setup(crs => crs.VerifyTokens(It.IsAny>(), It.IsAny>(), + It.IsAny>())) + .Returns(true); + + await _middleware.InvokeAsync(context); + + context.Response.Should().BeAProblem(HttpStatusCode.Forbidden, + Resources.CSRFMiddleware_OriginMismatched); + _csrfService.Setup(crs => crs.VerifyTokens("ananticsrfheader", "ananticsrfcookie", "auserid")) + .Returns(false); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenInvokeAsyncAndTokensIsVerifiedButRefererNotHost_ThenReturnsError() + { + var tokenForUser = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + claims: new Claim[] + { + new(AuthenticationConstants.Claims.ForId, "auserid") + } + )); + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { + { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" }, + { AuthenticationConstants.Cookies.Token, tokenForUser } + }); + context.Request.Headers.Add(CSRFConstants.Headers.AntiCSRF, new StringValues("ananticsrfheader")); + context.Request.Headers.Add(HttpHeaders.Origin, new StringValues(string.Empty)); + context.Request.Headers.Add(HttpHeaders.Referer, new StringValues("anotherhostname")); + _csrfService.Setup(crs => crs.VerifyTokens(It.IsAny>(), It.IsAny>(), + It.IsAny>())) + .Returns(true); + + await _middleware.InvokeAsync(context); + + context.Response.Should().BeAProblem(HttpStatusCode.Forbidden, + Resources.CSRFMiddleware_RefererMismatched); + _csrfService.Setup(crs => crs.VerifyTokens("ananticsrfheader", "ananticsrfcookie", "auserid")) + .Returns(false); + _next.Verify(n => n.Invoke(It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenInvokeAsyncAndTokensIsVerifiedAndOriginIsHostForUser_ThenContinuesPipeline() + { + var tokenForUser = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + claims: new Claim[] + { + new(AuthenticationConstants.Claims.ForId, "auserid") + } + )); + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { + { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" }, + { AuthenticationConstants.Cookies.Token, tokenForUser } + }); + context.Request.Headers.Add(CSRFConstants.Headers.AntiCSRF, new StringValues("ananticsrfheader")); + context.Request.Headers.Add(HttpHeaders.Origin, new StringValues("https://localhost")); + context.Request.Headers.Add(HttpHeaders.Referer, new StringValues(string.Empty)); + _csrfService.Setup(crs => crs.VerifyTokens(It.IsAny>(), It.IsAny>(), + It.IsAny>())) + .Returns(true); + + await _middleware.InvokeAsync(context); + + context.Response.Should().NotBeAProblem(); + _csrfService.Setup(crs => crs.VerifyTokens("ananticsrfheader", "ananticsrfcookie", "auserid")) + .Returns(false); + _next.Verify(n => n.Invoke(context)); + } + + [Fact] + public async Task WhenInvokeAsyncAndTokensIsVerifiedAndRefererIsHostForUser_ThenContinuesPipeline() + { + var tokenForUser = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + claims: new Claim[] + { + new(AuthenticationConstants.Claims.ForId, "auserid") + } + )); + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { + { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" }, + { AuthenticationConstants.Cookies.Token, tokenForUser } + }); + context.Request.Headers.Add(CSRFConstants.Headers.AntiCSRF, new StringValues("ananticsrfheader")); + context.Request.Headers.Add(HttpHeaders.Origin, new StringValues(string.Empty)); + context.Request.Headers.Add(HttpHeaders.Referer, new StringValues("https://localhost")); + _csrfService.Setup(crs => crs.VerifyTokens(It.IsAny>(), It.IsAny>(), + It.IsAny>())) + .Returns(true); + + await _middleware.InvokeAsync(context); + + context.Response.Should().NotBeAProblem(); + _csrfService.Setup(crs => crs.VerifyTokens("ananticsrfheader", "ananticsrfcookie", "auserid")) + .Returns(false); + _next.Verify(n => n.Invoke(context)); + } + + [Fact] + public async Task WhenInvokeAsyncAndTokensIsVerifiedAndOriginIsHostForNoUser_ThenContinuesPipeline() + { + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { + { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" } + }); + context.Request.Headers.Add(CSRFConstants.Headers.AntiCSRF, new StringValues("ananticsrfheader")); + context.Request.Headers.Add(HttpHeaders.Origin, new StringValues("https://localhost")); + context.Request.Headers.Add(HttpHeaders.Referer, new StringValues(string.Empty)); + _csrfService.Setup(crs => crs.VerifyTokens(It.IsAny>(), It.IsAny>(), + It.IsAny>())) + .Returns(true); + + await _middleware.InvokeAsync(context); + + context.Response.Should().NotBeAProblem(); + _csrfService.Setup(crs => crs.VerifyTokens("ananticsrfheader", "ananticsrfcookie", Optional.None)) + .Returns(false); + _next.Verify(n => n.Invoke(context)); + } + + [Fact] + public async Task WhenInvokeAsyncAndTokensIsVerifiedAndRefererIsHostForNoUser_ThenContinuesPipeline() + { + var context = SetupContext(); + context.Request.Cookies = SetupCookies(new Dictionary + { + { CSRFConstants.Cookies.AntiCSRF, "ananticsrfcookie" } + }); + context.Request.Headers.Add(CSRFConstants.Headers.AntiCSRF, new StringValues("ananticsrfheader")); + context.Request.Headers.Add(HttpHeaders.Origin, new StringValues(string.Empty)); + context.Request.Headers.Add(HttpHeaders.Referer, new StringValues("https://localhost")); + _csrfService.Setup(crs => crs.VerifyTokens(It.IsAny>(), It.IsAny>(), + It.IsAny>())) + .Returns(true); + + await _middleware.InvokeAsync(context); + + context.Response.Should().NotBeAProblem(); + _csrfService.Setup(crs => crs.VerifyTokens("ananticsrfheader", "ananticsrfcookie", Optional.None)) + .Returns(false); + _next.Verify(n => n.Invoke(context)); + } + + private DefaultHttpContext SetupContext() + { + var context = new DefaultHttpContext + { + Request = { Method = HttpMethods.Post }, + RequestServices = _serviceProvider, + Response = + { + StatusCode = 200, + Body = new MemoryStream() + } + }; + return context; + } + + private static IRequestCookieCollection SetupCookies(Dictionary values) + { + var cookies = new Mock(); + foreach (var value in values) + { + cookies.Setup(c => c.TryGetValue(value.Key, out It.Ref.IsAny)) + .Returns((string _, ref string? val) => + { + val = value.Value; + return value.Value.HasValue(); + }); + } + + return cookies.Object; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFServiceSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFServiceSpec.cs new file mode 100644 index 00000000..e9cb20f4 --- /dev/null +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFServiceSpec.cs @@ -0,0 +1,133 @@ +using Application.Interfaces.Services; +using Common; +using FluentAssertions; +using Infrastructure.Common.DomainServices; +using Infrastructure.Web.Hosting.Common.Pipeline; +using Moq; +using UnitTesting.Common; +using Xunit; + +namespace Infrastructure.Web.Hosting.Common.UnitTests.Pipeline; + +[Trait("Category", "Unit")] +public class CSRFServiceSpec +{ + private readonly AesEncryptionService _encryptionService; + private readonly CSRFService _service; + + public CSRFServiceSpec() + { + var settings = new Mock(); + settings.Setup(s => s.GetWebsiteHostCSRFSigningSecret()) + .Returns("asecret"); +#if TESTINGONLY + _encryptionService = new AesEncryptionService(AesEncryptionService.CreateAesSecret()); +#endif + + _service = new CSRFService(settings.Object, _encryptionService); + } + + [Fact] + public void WhenCreateTokensWithNoUserId_ThenReturnsPair() + { + var result = _service.CreateTokens(Optional.None); + + result.Should().NotBeNull(); + result.Token.Should().NotBeNone(); + result.Signature.Should().NotBeNone(); + } + + [Fact] + public void WhenCreateTokensWithUserId_ThenReturnsPair() + { + var result = _service.CreateTokens("auserid"); + + result.Should().NotBeNull(); + result.Token.Should().NotBeNone(); + result.Signature.Should().NotBeNone(); + } + + [Fact] + public void WhenVerifyTokensAndTokenIsNone_ThenReturnsFalse() + { + var result = _service.VerifyTokens(Optional.None, "asignature", Optional.None); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyTokensAndSignatureIsNone_ThenReturnsFalse() + { + var result = _service.VerifyTokens("atoken", Optional.None, Optional.None); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyTokensAndSignatureIsInvalidForNoUser_ThenReturnsFalse() + { + var token = CSRFTokenPair.CreateTokens(_encryptionService, "asecret", Optional.None).Token; + + var result = _service.VerifyTokens(token, "awrongsignature", Optional.None); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyTokensAndSignatureIsInvalidForUser_ThenReturnsFalse() + { + var token = CSRFTokenPair.CreateTokens(_encryptionService, "asecret", "auserid").Token; + + var result = _service.VerifyTokens(token, "awrongsignature", "auserid"); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyTokensAndSignatureIsValidForUserButNotForNoUser_ThenReturnsFalse() + { + var pair = CSRFTokenPair.CreateTokens(_encryptionService, "asecret", "auserid"); + var token = pair.Token; + var signature = pair.Signature; + + var result = _service.VerifyTokens(token, signature, Optional.None); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyTokensAndSignatureIsValidForNoUserButNotForUser_ThenReturnsFalse() + { + var pair = CSRFTokenPair.CreateTokens(_encryptionService, "asecret", Optional.None); + var token = pair.Token; + var signature = pair.Signature; + + var result = _service.VerifyTokens(token, signature, "auserid"); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyTokensAndSignatureIsValidForNoUser_ThenReturnsTrue() + { + var pair = CSRFTokenPair.CreateTokens(_encryptionService, "asecret", Optional.None); + var token = pair.Token; + var signature = pair.Signature; + + var result = _service.VerifyTokens(token, signature, Optional.None); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenVerifyTokensAndSignatureIsValidForUser_ThenReturnsTrue() + { + var pair = CSRFTokenPair.CreateTokens(_encryptionService, "asecret", "auserid"); + var token = pair.Token; + var signature = pair.Signature; + + var result = _service.VerifyTokens(token, signature, "auserid"); + + result.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFTokenPairSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFTokenPairSpec.cs new file mode 100644 index 00000000..d53a56d4 --- /dev/null +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/CSRFTokenPairSpec.cs @@ -0,0 +1,246 @@ +using Common; +using FluentAssertions; +using Infrastructure.Common.DomainServices; +using Infrastructure.Web.Hosting.Common.Pipeline; +using UnitTesting.Common; +using Xunit; + +namespace Infrastructure.Web.Hosting.Common.UnitTests.Pipeline; + +[Trait("Category", "Unit")] +public class CSRFTokenPairSpec +{ + private const string HmacSecret = "asecret"; + private readonly AesEncryptionService _encryptionService; + + public CSRFTokenPairSpec() + { +#if TESTINGONLY + _encryptionService = + new AesEncryptionService(AesEncryptionService.CreateAesSecret()); +#endif + } + + [Fact] + public void WhenFromTokens_ThenReturnsPair() + { + var result = CSRFTokenPair.FromTokens("atoken", "asignature"); + + result.Token.Should().Be("atoken"); + result.Signature.Should().Be("asignature"); + } + + [Fact] + public void WhenFromTokensWithNone_ThenReturnsPair() + { + var result = CSRFTokenPair.FromTokens(Optional.None, Optional.None); + + result.Token.Should().BeNone(); + result.Signature.Should().BeNone(); + } + + [Fact] + public void WhenIsValidAndTokenIsNone_ThenReturnsFalse() + { + var result = CSRFTokenPair.FromTokens(Optional.None, "asignature") + .IsValid(_encryptionService, HmacSecret, Optional.None); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenIsValidAndSignatureIsNone_ThenReturnsFalse() + { + var result = CSRFTokenPair.FromTokens("atoken", Optional.None) + .IsValid(_encryptionService, HmacSecret, Optional.None); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenIsValidAndSignatureAreNotMatched_ThenReturnsFalse() + { + var tokens1 = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid1"); + var tokens2 = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid2"); + + var result = CSRFTokenPair.FromTokens(tokens1.Token, tokens2.Signature) + .IsValid(_encryptionService, HmacSecret, Optional.None); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenIsValidAndSignatureAreMatched_ThenReturnsTrue() + { + var tokens = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid"); + + var result = CSRFTokenPair.FromTokens(tokens.Token, tokens.Signature) + .IsValid(_encryptionService, HmacSecret, "auserid"); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenCreateTokensAndHmacSecretIsEmpty_ThenThrows() + { + FluentActions.Invoking(() => CSRFTokenPair.CreateTokens(_encryptionService, string.Empty, "auserid")) + .Should().Throw(); + } + + [Fact] + public void WhenCreateTokensAndUserIdIsNone_ThenReturnsTokens() + { + var result = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, Optional.None); + + result.Token.Should().NotBeNone(); + result.Signature.Should().NotBeNone(); + } + + [Fact] + public void WhenCreateTokensAndUserId_ThenReturnsTokens() + { + var result = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid"); + + result.Token.Should().NotBeNone(); + result.Signature.Should().NotBeNone(); + } + + [Fact] + public void WhenCreateTokensAndUserIdIsNone_ThenSignatureAndTokenCannotBeSame() + { + var result = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, Optional.None); + + result.Token.Should().NotBe(result.Signature); + } + + [Fact] + public void WhenCreateTokensAndUserId_ThenSignatureAndTokenCannotBeSame() + { + var result = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid"); + + result.Token.Should().NotBe(result.Signature); + } + + [Fact] + public void WhenCreateTokensAndUserIdIsNone_ThenReturnedTokensCannotBeTheNullUserId() + { + var result = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, Optional.None); + + result.Token.Should().NotBe(CSRFTokenPair.NullUserIdTokenValue); + result.Signature.Should().NotBeNone(); + } + + [Fact] + public void WhenCreateTokensAndUserId_ThenReturnedTokensCannotBeTheUserId() + { + var result = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid"); + + result.Token.Should().NotBe("auserid"); + result.Signature.Should().NotBeNone(); + } + + [Fact] + public void WhenCreateTokensMultipleTimesForSameUserId_ThenTokensMustBeUniqueWithSameSignature() + { + var result1 = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid"); + var result2 = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid"); + + result1.Token.Should().NotBe(result2.Token); + result1.Signature.Should().Be(result2.Signature); + } + + [Fact] + public void WhenCreateTokensMultipleTimesForSameNoneUserId_ThenTokensMustBeUniqueWithSameSignature() + { + var result1 = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, Optional.None); + var result2 = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, Optional.None); + + result1.Token.Should().NotBe(result2.Token); + result1.Signature.Should().Be(result2.Signature); + } + + [Fact] + public void WhenIsValidWithNoneUserIdAndTokenIsForNoneUserId_ThenReturnsTrue() + { + var tokens = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, Optional.None); + + var result = CSRFTokenPair.FromTokens(tokens.Token, tokens.Signature) + .IsValid(_encryptionService, HmacSecret, Optional.None); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenIsValidWithUserIdAndTokenIsForSameUserId_ThenReturnsTrue() + { + var tokens = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid"); + + var result = CSRFTokenPair.FromTokens(tokens.Token, tokens.Signature) + .IsValid(_encryptionService, HmacSecret, "auserid"); + + result.Should().BeTrue(); + } + + [Fact] + public void WhenIsValidWithOneUserIdAndTokenIsForDifferentUserId_ThenReturnsFalse() + { + var tokens = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid1"); + + var result = CSRFTokenPair.FromTokens(tokens.Token, tokens.Signature) + .IsValid(_encryptionService, HmacSecret, "auserid2"); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenIsValidWithOneUserIdAndTokenIsForNoneUserId_ThenReturnsFalse() + { + var tokens = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid1"); + + var result = CSRFTokenPair.FromTokens(tokens.Token, tokens.Signature) + .IsValid(_encryptionService, HmacSecret, Optional.None); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenIsValidWithNoneUserIdAndTokenIsForOneUserId_ThenReturnsFalse() + { + var tokens = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, null); + + var result = CSRFTokenPair.FromTokens(tokens.Token, tokens.Signature) + .IsValid(_encryptionService, HmacSecret, "auserid"); + + result.Should().BeFalse(); + } + + [Fact] + public void WhenIsValidWithNoneUserIdAndDifferentTokensForNoneUserId_ThenReturnsTrue() + { + var tokens1 = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, Optional.None); + var tokens2 = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, Optional.None); + + var result1 = CSRFTokenPair.FromTokens(tokens1.Token, tokens2.Signature) + .IsValid(_encryptionService, HmacSecret, Optional.None); + var result2 = CSRFTokenPair.FromTokens(tokens2.Token, tokens1.Signature) + .IsValid(_encryptionService, HmacSecret, Optional.None); + + result1.Should().BeTrue(); + result2.Should().BeTrue(); + } + + [Fact] + public void WhenIsValidWithOneUserIdAndDifferentTokensForOneUserId_ThenReturnsTrue() + { + var tokens1 = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid"); + var tokens2 = CSRFTokenPair.CreateTokens(_encryptionService, HmacSecret, "auserid"); + + var result1 = CSRFTokenPair.FromTokens(tokens1.Token, tokens2.Signature) + .IsValid(_encryptionService, HmacSecret, "auserid"); + var result2 = CSRFTokenPair.FromTokens(tokens2.Token, tokens1.Signature) + .IsValid(_encryptionService, HmacSecret, "auserid"); + + result1.Should().BeTrue(); + result2.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Common.UnitTests/ReverseProxyMiddlewareSpec.cs b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/ReverseProxyMiddlewareSpec.cs similarity index 96% rename from src/Infrastructure.Web.Common.UnitTests/ReverseProxyMiddlewareSpec.cs rename to src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/ReverseProxyMiddlewareSpec.cs index 552b7ad9..05824623 100644 --- a/src/Infrastructure.Web.Common.UnitTests/ReverseProxyMiddlewareSpec.cs +++ b/src/Infrastructure.Web.Hosting.Common.UnitTests/Pipeline/ReverseProxyMiddlewareSpec.cs @@ -1,11 +1,12 @@ using System.Net; using Application.Interfaces.Services; +using Infrastructure.Web.Hosting.Common.Pipeline; using Microsoft.AspNetCore.Http; using Moq; using Moq.Protected; using Xunit; -namespace Infrastructure.Web.Common.UnitTests; +namespace Infrastructure.Web.Hosting.Common.UnitTests.Pipeline; [Trait("Category", "Unit")] public class ReverseProxyMiddlewareSpec diff --git a/src/Infrastructure.Web.Hosting.Common/Extensions/RequestExtensions.cs b/src/Infrastructure.Web.Hosting.Common/Extensions/RequestExtensions.cs new file mode 100644 index 00000000..677ce9e9 --- /dev/null +++ b/src/Infrastructure.Web.Hosting.Common/Extensions/RequestExtensions.cs @@ -0,0 +1,60 @@ +using System.IdentityModel.Tokens.Jwt; +using Common; +using Common.Extensions; +using Infrastructure.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace Infrastructure.Web.Hosting.Common.Extensions; + +public static class RequestExtensions +{ + /// + /// Returns the ID of the user from the JWT token in the Cookie + /// + public static Result, Error> GetUserIdFromAuthNCookie(this HttpRequest request) + { + var token = GetAuthNCookie(request); + if (!token.HasValue) + { + return Optional.None; + } + + var userId = GetUserIdClaim(token); + if (!userId.IsSuccessful) + { + return userId.Error; + } + + return userId.Value.ToOptional(); + } + + private static Optional GetAuthNCookie(HttpRequest request) + { + if (request.Cookies.TryGetValue(AuthenticationConstants.Cookies.Token, out var value)) + { + return value; + } + + return Optional.None; + } + + private static Result GetUserIdClaim(string token) + { + try + { + var claims = new JwtSecurityTokenHandler().ReadJwtToken(token).Claims.ToArray(); + var userClaim = claims + .FirstOrDefault(claim => claim.Type == AuthenticationConstants.Claims.ForId); + if (userClaim.NotExists()) + { + return Error.ForbiddenAccess(Resources.CSRFMiddleware_InvalidToken); + } + + return userClaim.Value; + } + catch (Exception) + { + return Error.ForbiddenAccess(Resources.CSRFMiddleware_InvalidToken); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs b/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs index f7d73c41..9b5b7d2b 100644 --- a/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs +++ b/src/Infrastructure.Web.Hosting.Common/Extensions/WebApplicationExtensions.cs @@ -9,6 +9,7 @@ using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Common; using Infrastructure.Web.Hosting.Common.ApplicationServices; +using Infrastructure.Web.Hosting.Common.Pipeline; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Diagnostics; @@ -43,9 +44,14 @@ public static void AddBEFFE(this WebApplication builder, middlewares.Add(new MiddlewareRegistration(30, app => { app.UsePathBase(new PathString(WebConstants.BackEndForFrontEndBasePath)); }, "Pipeline: Website API is enabled: Route -> {Route}", WebConstants.BackEndForFrontEndBasePath)); - middlewares.Add(new MiddlewareRegistration(31, app => + middlewares.Add(new MiddlewareRegistration(35, app => { - app.UseDefaultFiles(); + if (!app.Environment.IsDevelopment()) + { + app.UseHsts(); + } + + app.UseHttpsRedirection(); app.UseStaticFiles(); }, "Pipeline: Serving static HTML/CSS/JS is enabled")); middlewares.Add(new MiddlewareRegistration(CustomMiddlewareIndex + 100, app => diff --git a/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj b/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj index 9945c0fc..ca0b9edd 100644 --- a/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj +++ b/src/Infrastructure.Web.Hosting.Common/Infrastructure.Web.Hosting.Common.csproj @@ -26,6 +26,9 @@ <_Parameter1>$(AssemblyName).UnitTests + + <_Parameter1>Infrastructure.Web.Website.IntegrationTests + diff --git a/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFConstants.cs b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFConstants.cs new file mode 100644 index 00000000..03b6f247 --- /dev/null +++ b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFConstants.cs @@ -0,0 +1,24 @@ +using Infrastructure.Web.Api.Common; + +namespace Infrastructure.Web.Hosting.Common.Pipeline; + +public static class CSRFConstants +{ + public static class Html + { + public const string CSRFFieldNamePlaceholder = "%%CSRFFIELDNAME%%"; + public const string CSRFTokenPlaceholder = "%%CSRFTOKEN%%"; + public const string CSRFRequestFieldName = "csrf-token"; + } + + public static class Cookies + { + public const string AntiCSRF = HttpHeaders.AntiCSRF; + public static readonly TimeSpan DefaultCSRFExpiry = TimeSpan.FromDays(14); + } + + public static class Headers + { + public const string AntiCSRF = HttpHeaders.AntiCSRF; + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFMiddleware.cs b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFMiddleware.cs new file mode 100644 index 00000000..b57ec452 --- /dev/null +++ b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFMiddleware.cs @@ -0,0 +1,232 @@ +using Application.Interfaces.Services; +using Common; +using Common.Extensions; +using Infrastructure.Web.Api.Common; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Hosting.Common.Extensions; +using Microsoft.AspNetCore.Http; + +namespace Infrastructure.Web.Hosting.Common.Pipeline; + +/// +/// Provides middleware to ensure that incoming requests have not been spoofed via CSRF attacks in the browser. +/// Implements several schemes, as per OWASP: +/// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html +/// 1. Double Submit Cookie, AND +/// 2. Verifying Origin With Standard Headers +/// +public sealed class CSRFMiddleware +{ + private static readonly string[] IgnoredMethods = + { + HttpMethods.Get, + HttpMethods.Head, + HttpMethods.Options + }; + private readonly ICSRFService _csrfService; + private readonly IHostSettings _hostSettings; + private readonly RequestDelegate _next; + private readonly IRecorder _recorder; + + public CSRFMiddleware(RequestDelegate next, IRecorder recorder, IHostSettings hostSettings, + ICSRFService csrfService) + { + _next = next; + _recorder = recorder; + _hostSettings = hostSettings; + _csrfService = csrfService; + } + + public async Task InvokeAsync(HttpContext context) + { + var request = context.Request; + if (IgnoredMethods.Contains(request.Method)) + { + await _next(context); //Continue down the pipeline + return; + } + + var result = VerifyRequest(request); + if (!result.IsSuccessful) + { + var httpError = result.Error.ToHttpError(); + var details = Results.Problem(statusCode: (int)httpError.Code, detail: httpError.Message); + await details + .ExecuteAsync(context); + return; + } + + await _next(context); //Continue down the pipeline + } + + private Result VerifyRequest(HttpRequest request) + { + var hostName = GetHostName(_hostSettings); + if (!hostName.IsSuccessful) + { + return hostName.Error; + } + + var csrfCookie = GetCookie(request); + if (!csrfCookie.IsSuccessful) + { + return csrfCookie.Error; + } + + var csrfHeader = GetHeader(request); + if (!csrfHeader.IsSuccessful) + { + return csrfHeader.Error; + } + + var userId = request.GetUserIdFromAuthNCookie(); + if (!userId.IsSuccessful) + { + return userId.Error; + } + + var verifiedCookie = + VerifyCookieAndHeaderForUser(_recorder, _csrfService, csrfCookie.Value, csrfHeader.Value, userId.Value); + if (!verifiedCookie.IsSuccessful) + { + return verifiedCookie.Error; + } + + var originHeader = GetHeader(request, HttpHeaders.Origin); + var refererHeader = GetHeader(request, HttpHeaders.Referer); + + var verifiedOrigin = VerifyOrigin(_recorder, hostName.Value, originHeader, refererHeader); + if (!verifiedOrigin.IsSuccessful) + { + return verifiedOrigin.Error; + } + + return Result.Ok; + } + + private static Result VerifyOrigin(IRecorder recorder, string hostName, Optional origin, + Optional referer) + { + if (!origin.HasValue && !referer.HasValue) + { + return Error.ForbiddenAccess(Resources.CSRFMiddleware_MissingOriginAndReferer); + } + + if (origin.HasValue) + { + var originHost = GetHost(origin.Value); + if (!originHost.HasValue || originHost.Value.NotEqualsIgnoreCase(hostName)) + { + recorder.TraceError(null, + $"Request '{HttpHeaders.Origin}' is not from a trusted site: '{{Origin}}'", + origin); + return Error.ForbiddenAccess(Resources.CSRFMiddleware_OriginMismatched); + } + } + + if (referer.HasValue) + { + var refererHost = GetHost(referer.Value); + if (!refererHost.HasValue || refererHost.Value.NotEqualsIgnoreCase(hostName)) + { + recorder.TraceError(null, + $"Request '{HttpHeaders.Referer}' is not from a trusted site: '{{Referer}}'", + origin); + return Error.ForbiddenAccess(Resources.CSRFMiddleware_RefererMismatched); + } + } + + return Result.Ok; + } + + private static Result VerifyCookieAndHeaderForUser(IRecorder recorder, ICSRFService csrfService, + string csrfCookie, string csrfHeader, Optional userId) + { + if (csrfCookie.HasNoValue() || csrfHeader.HasNoValue()) + { + return Error.ForbiddenAccess(Resources.CSRFMiddleware_MissingCSRFCredentials); + } + + var isVerified = csrfService.VerifyTokens(csrfHeader, csrfCookie, userId); + if (isVerified) + { + return Result.Ok; + } + + recorder.TraceError(null, + "Request contains an invalid CSRF cookie signature for the CSRF token, and for the current user"); + return Error.ForbiddenAccess(Resources.CSRFMiddleware_InvalidSignature.Format(userId)); + } + + private static Result GetHeader(HttpRequest request) + { + var header = GetHeader(request, CSRFConstants.Headers.AntiCSRF); + if (header.HasValue) + { + return header.Value; + } + + return Error.ForbiddenAccess(Resources.CSRFMiddleware_MissingCSRFHeaderValue); + } + + private static Optional GetHeader(HttpRequest request, string name) + { + if (request.Headers.TryGetValue(name, out var value)) + { + var values = value.ToString(); + + return values.HasValue() + ? values + : Optional.None; + } + + return Optional.None; + } + + private static Result GetCookie(HttpRequest request) + { + if (request.Cookies.TryGetValue(CSRFConstants.Cookies.AntiCSRF, out var value)) + { + return value; + } + + return Error.ForbiddenAccess(Resources.CSRFMiddleware_MissingCSRFCookieValue); + } + + private static Result GetHostName(IHostSettings settings) + { + var baseUrl = settings.GetWebsiteHostBaseUrl(); + if (baseUrl.HasNoValue()) + { + return Error.Unexpected(Resources.CSRFMiddleware_InvalidHostName.Format(baseUrl)); + } + + var hostName = GetHost(baseUrl); + if (hostName.HasValue) + { + return hostName.Value; + } + + return Error.Unexpected(Resources.CSRFMiddleware_InvalidHostName.Format(baseUrl)); + } + + private static Optional GetHost(string name) + { + if (!Uri.TryCreate(name, UriKind.Absolute, out var uri)) + { + return Optional.None; + } + + return uri.Host; + } + + /// + /// Defines a service for creating and verifying CSRF token pairs + /// + public interface ICSRFService + { + CSRFTokenPair CreateTokens(Optional userId); + + bool VerifyTokens(Optional token, Optional signature, Optional userId); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFService.cs b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFService.cs new file mode 100644 index 00000000..a04df7ac --- /dev/null +++ b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFService.cs @@ -0,0 +1,41 @@ +using Application.Interfaces.Services; +using Common; +using Domain.Services.Shared.DomainServices; + +namespace Infrastructure.Web.Hosting.Common.Pipeline; + +/// +/// Provides a service for creating and verifying CSRF tokens +/// +public class CSRFService : CSRFMiddleware.ICSRFService +{ + private readonly IEncryptionService _encryptionService; + private readonly string _hmacSecret; + + public CSRFService(IHostSettings settings, IEncryptionService encryptionService) + { + _hmacSecret = settings.GetWebsiteHostCSRFSigningSecret(); + _encryptionService = encryptionService; + } + + public CSRFTokenPair CreateTokens(Optional userId) + { + return CSRFTokenPair.CreateTokens(_encryptionService, _hmacSecret, userId); + } + + public bool VerifyTokens(Optional token, Optional signature, Optional userId) + { + if (!token.HasValue) + { + return false; + } + + if (!signature.HasValue) + { + return false; + } + + return CSRFTokenPair.FromTokens(token, signature) + .IsValid(_encryptionService, _hmacSecret, userId); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFTokenPair.cs b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFTokenPair.cs new file mode 100644 index 00000000..2524decb --- /dev/null +++ b/src/Infrastructure.Web.Hosting.Common/Pipeline/CSRFTokenPair.cs @@ -0,0 +1,141 @@ +using Common; +using Common.Extensions; +using Domain.Interfaces; +using Domain.Services.Shared.DomainServices; +using Infrastructure.Web.Api.Common; + +namespace Infrastructure.Web.Hosting.Common.Pipeline; + +/// +/// Creates a Token with a Signature. +/// The part contains a AES(256) combined value of: user_identifier + timestamp, +/// The part is a HMAC signature of the user_identifier. +/// +public class CSRFTokenPair +{ + internal const string NullUserIdTokenValue = CallerConstants.AnonymousUserId; + private const string TokenDelimiter = "||"; + + private CSRFTokenPair(Optional token, Optional signature) + { + Token = token; + Signature = signature; + } + + public Optional Signature { get; } + + public Optional Token { get; } + + public static CSRFTokenPair CreateTokens(IEncryptionService encryptionService, string hmacSecret, + Optional userId) + { + hmacSecret.ThrowIfNotValuedParameter(nameof(hmacSecret)); + + var qualifiedUserId = QualifyUserId(userId); + var token = CreateEncryptedTokenForQualifiedUserId(encryptionService, qualifiedUserId); + var signature = SignQualifiedUserId(hmacSecret, qualifiedUserId); + + return new CSRFTokenPair(token, signature); + } + + public static CSRFTokenPair FromTokens(Optional token, Optional signature) + { + return new CSRFTokenPair(token, signature); + } + + public bool IsValid(IEncryptionService encryptionService, string hmacSecret, Optional userId) + { + hmacSecret.ThrowIfNotValuedParameter(nameof(hmacSecret)); + + if (!Token.HasValue) + { + return false; + } + + if (!Signature.HasValue) + { + return false; + } + + var tokenValue = DecryptTokenValue(encryptionService, Token); + var qualifiedUserId = GetUserIdFromTokenValue(tokenValue); + var isVerified = VerifyQualifiedUserIdSignature(hmacSecret, qualifiedUserId, Signature); + if (!isVerified) + { + return false; + } + + return IsQualifiedUserId(qualifiedUserId, userId); + } + + private static bool IsQualifiedUserId(string qualifiedUserId, string userId) + { + return userId.HasNoValue() + ? qualifiedUserId == NullUserIdTokenValue + : qualifiedUserId == userId; + } + + private static string QualifyUserId(string userId) + { + return userId.HasValue() + ? userId + : NullUserIdTokenValue; + } + + private static string GetUserIdFromTokenValue(string tokenValue) + { + return tokenValue.Substring(0, tokenValue.IndexOf(TokenDelimiter, StringComparison.Ordinal)); + } + + private static string CreateTokenValueFromUserId(string userId) + { + return $"{userId}{TokenDelimiter}{DateTime.UtcNow.Ticks}"; + } + + private static string CreateEncryptedTokenForQualifiedUserId(IEncryptionService encryptionService, + string qualifiedUserId) + { + var tokenValue = CreateTokenValueFromUserId(qualifiedUserId); + + return EncryptTokenValue(encryptionService, tokenValue); + } + + private static string SignQualifiedUserId(string hmacSecret, string qualifiedUserId) + { + var signer = new HMACSigner(qualifiedUserId, hmacSecret); + return signer.Sign(); + } + + private static bool VerifyQualifiedUserIdSignature(string hmacSecret, string qualifiedUserId, string signature) + { + var signer = new HMACSigner(qualifiedUserId, hmacSecret); + var verifier = new HMACVerifier(signer); + return verifier.Verify(signature); + } + + private static string EncryptTokenValue(IEncryptionService encryptionService, string userId) + { + userId.ThrowIfNotValuedParameter(nameof(userId)); + + try + { + return encryptionService.Encrypt(userId); + } + catch (Exception ex) + { + throw new InvalidOperationException(Resources.CSFRTokenPair_FailedEncryptUserId, ex); + } + } + + private static string DecryptTokenValue(IEncryptionService encryptionService, string encryptedUserId) + { + try + { + return encryptionService.Decrypt(encryptedUserId); + } + catch (Exception ex) + { + throw new InvalidOperationException(Resources.CSRFTokenPair_FailedDecryptUserId, ex); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Common/MultiTenancyMiddleware.cs b/src/Infrastructure.Web.Hosting.Common/Pipeline/MultiTenancyMiddleware.cs similarity index 91% rename from src/Infrastructure.Web.Common/MultiTenancyMiddleware.cs rename to src/Infrastructure.Web.Hosting.Common/Pipeline/MultiTenancyMiddleware.cs index a2605f32..dc292119 100644 --- a/src/Infrastructure.Web.Common/MultiTenancyMiddleware.cs +++ b/src/Infrastructure.Web.Hosting.Common/Pipeline/MultiTenancyMiddleware.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http; -namespace Infrastructure.Web.Common; +namespace Infrastructure.Web.Hosting.Common.Pipeline; /// /// Provides middleware to detect the tenant of incoming requests diff --git a/src/Infrastructure.Web.Common/ReverseProxyMiddleware.cs b/src/Infrastructure.Web.Hosting.Common/Pipeline/ReverseProxyMiddleware.cs similarity index 98% rename from src/Infrastructure.Web.Common/ReverseProxyMiddleware.cs rename to src/Infrastructure.Web.Hosting.Common/Pipeline/ReverseProxyMiddleware.cs index 186669c0..9af2e237 100644 --- a/src/Infrastructure.Web.Common/ReverseProxyMiddleware.cs +++ b/src/Infrastructure.Web.Hosting.Common/Pipeline/ReverseProxyMiddleware.cs @@ -2,10 +2,11 @@ using Common.Extensions; using Infrastructure.Interfaces; using Infrastructure.Web.Api.Common; +using Infrastructure.Web.Common; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; -namespace Infrastructure.Web.Common; +namespace Infrastructure.Web.Hosting.Common.Pipeline; /// /// Provides middleware to reverse proxy all (non-hosted) API requests to the Backend API. diff --git a/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs b/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs index 7f3e7f3c..b6c27561 100644 --- a/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs +++ b/src/Infrastructure.Web.Hosting.Common/Resources.Designer.cs @@ -104,6 +104,105 @@ internal static string CORS_MissingSameOrigins { } } + /// + /// Looks up a localized string similar to Failed to AES encrypt the user ID. + /// + internal static string CSFRTokenPair_FailedEncryptUserId { + get { + return ResourceManager.GetString("CSFRTokenPair_FailedEncryptUserId", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Host of the base URL could not be determined from: '{0}'. + /// + internal static string CSRFMiddleware_InvalidHostName { + get { + return ResourceManager.GetString("CSRFMiddleware_InvalidHostName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The CSRF cookie and/or header are invalid for the current user: '{0}'. + /// + internal static string CSRFMiddleware_InvalidSignature { + get { + return ResourceManager.GetString("CSRFMiddleware_InvalidSignature", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The JWT access_token is invalid in the request. + /// + internal static string CSRFMiddleware_InvalidToken { + get { + return ResourceManager.GetString("CSRFMiddleware_InvalidToken", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The CSRF cookie is missing from the request. + /// + internal static string CSRFMiddleware_MissingCSRFCookieValue { + get { + return ResourceManager.GetString("CSRFMiddleware_MissingCSRFCookieValue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The CSRF cookie and/or the CSRF header are invalid in the request. + /// + internal static string CSRFMiddleware_MissingCSRFCredentials { + get { + return ResourceManager.GetString("CSRFMiddleware_MissingCSRFCredentials", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The CSRF header is missing from the request. + /// + internal static string CSRFMiddleware_MissingCSRFHeaderValue { + get { + return ResourceManager.GetString("CSRFMiddleware_MissingCSRFHeaderValue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Origin and Referer headers are missing from the request. + /// + internal static string CSRFMiddleware_MissingOriginAndReferer { + get { + return ResourceManager.GetString("CSRFMiddleware_MissingOriginAndReferer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Origin of the request does not match the server. + /// + internal static string CSRFMiddleware_OriginMismatched { + get { + return ResourceManager.GetString("CSRFMiddleware_OriginMismatched", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Referer of the request does not match the server. + /// + internal static string CSRFMiddleware_RefererMismatched { + get { + return ResourceManager.GetString("CSRFMiddleware_RefererMismatched", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to AES decrypt the user ID. + /// + internal static string CSRFTokenPair_FailedDecryptUserId { + get { + return ResourceManager.GetString("CSRFTokenPair_FailedDecryptUserId", resourceCulture); + } + } + /// /// Looks up a localized string similar to Failed HMAC authentication. /// diff --git a/src/Infrastructure.Web.Hosting.Common/Resources.resx b/src/Infrastructure.Web.Hosting.Common/Resources.resx index 3dee87fd..8955f64f 100644 --- a/src/Infrastructure.Web.Hosting.Common/Resources.resx +++ b/src/Infrastructure.Web.Hosting.Common/Resources.resx @@ -54,4 +54,37 @@ Invalid subscription level + + Failed to AES encrypt the user ID + + + Failed to AES decrypt the user ID + + + The Host of the base URL could not be determined from: '{0}' + + + The CSRF cookie is missing from the request + + + The CSRF header is missing from the request + + + The JWT access_token is invalid in the request + + + The CSRF cookie and/or the CSRF header are invalid in the request + + + The CSRF cookie and/or header are invalid for the current user: '{0}' + + + The Origin and Referer headers are missing from the request + + + The Origin of the request does not match the server + + + The Referer of the request does not match the server + \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.IntegrationTests/AuthNApiSpec.cs b/src/Infrastructure.Web.Website.IntegrationTests/AuthNApiSpec.cs index b8cefffd..b9288bf0 100644 --- a/src/Infrastructure.Web.Website.IntegrationTests/AuthNApiSpec.cs +++ b/src/Infrastructure.Web.Website.IntegrationTests/AuthNApiSpec.cs @@ -5,6 +5,7 @@ using Infrastructure.Interfaces; using Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; using Infrastructure.Web.Api.Operations.Shared.TestingOnly; +using Infrastructure.Web.Hosting.Common.Pipeline; using IntegrationTesting.WebApi.Common; using UnitTesting.Common; using WebsiteHost; @@ -16,14 +17,16 @@ namespace Infrastructure.Web.Website.IntegrationTests; [Collection("API")] public class AuthNApiSpec : WebApiSpec { + private readonly CSRFMiddleware.ICSRFService _csrfService; private readonly JsonSerializerOptions _jsonOptions; public AuthNApiSpec(WebApiSetup setup) : base(setup) { StartupServer(); + _csrfService = setup.GetRequiredService(); #if TESTINGONLY - HttpApi.PostAsync(new DestroyAllRepositoriesRequest().MakeApiRoute(), JsonContent.Create(new { })).GetAwaiter() - .GetResult(); + HttpApi.PostEmptyJsonAsync(new DestroyAllRepositoriesRequest().MakeApiRoute(), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)).GetAwaiter().GetResult(); #endif _jsonOptions = setup.GetRequiredService(); } @@ -31,15 +34,16 @@ public AuthNApiSpec(WebApiSetup setup) : base(setup) [Fact] public async Task WhenRefreshTokenAndNoCookie_ThenReturnsError() { - var result = await HttpApi.PostAsync(new RefreshTokenRequest().MakeApiRoute(), JsonContent.Create(new { })); + var result = await HttpApi.PostEmptyJsonAsync(new RefreshTokenRequest().MakeApiRoute(), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)); - result.StatusCode.Should().Be(HttpStatusCode.NotFound); + result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } [Fact] public async Task WhenRefreshTokenAndAuthenticated_ThenReturnsNewTokens() { - var (_, response) = await HttpApi.LoginUserFromBrowserAsync(_jsonOptions); + var (userId, response) = await HttpApi.LoginUserFromBrowserAsync(_jsonOptions, _csrfService); var accessToken1 = response.GetCookie(AuthenticationConstants.Cookies.Token); var refreshToken1 = response.GetCookie(AuthenticationConstants.Cookies.RefreshToken); @@ -48,24 +52,26 @@ await Task.Delay(TimeSpan .FromSeconds(1)); //HACK: to ensure that the new token is not the same (in time) as the old token #if TESTINGONLY - var result = await HttpApi.PostAsync(new RefreshTokenRequest().MakeApiRoute(), JsonContent.Create(new { })); + var result = await HttpApi.PostEmptyJsonAsync(new RefreshTokenRequest().MakeApiRoute(), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService, userId)); result.StatusCode.Should().Be(HttpStatusCode.OK); -#endif var accessToken2 = result.GetCookie(AuthenticationConstants.Cookies.Token); var refreshToken2 = result.GetCookie(AuthenticationConstants.Cookies.RefreshToken); accessToken1.Should().NotBe(accessToken2); refreshToken1.Should().NotBe(refreshToken2); +#endif } [Fact] public async Task WhenAuthenticateAndWrongCredentials_ThenReturnsError() { - await HttpApi.RegisterPersonUserFromBrowserAsync(_jsonOptions, "auser@company.com", "1Password!"); + await HttpApi.RegisterPersonUserFromBrowserAsync(_jsonOptions, _csrfService, "auser@company.com", "1Password!"); var result = - await HttpApi.AuthenticateUserFromBrowserAsync(_jsonOptions, "auser@company.com", "1AnotherPassword!"); + await HttpApi.AuthenticateUserFromBrowserAsync(_jsonOptions, _csrfService, "auser@company.com", + "1AnotherPassword!"); result.Response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); } @@ -73,7 +79,7 @@ public async Task WhenAuthenticateAndWrongCredentials_ThenReturnsError() [Fact] public async Task WhenAuthenticateAndNotAuthenticated_ThenReturnsNewCookies() { - var (_, response) = await HttpApi.LoginUserFromBrowserAsync(_jsonOptions); + var (_, response) = await HttpApi.LoginUserFromBrowserAsync(_jsonOptions, _csrfService); var accessToken = response.GetCookie(AuthenticationConstants.Cookies.Token); var refreshToken = response.GetCookie(AuthenticationConstants.Cookies.RefreshToken); @@ -85,9 +91,10 @@ public async Task WhenAuthenticateAndNotAuthenticated_ThenReturnsNewCookies() [Fact] public async Task WhenLogoutAndAuthenticated_ThenReturnsWithNoCookies() { - await HttpApi.LoginUserFromBrowserAsync(_jsonOptions); + await HttpApi.LoginUserFromBrowserAsync(_jsonOptions, _csrfService); - var result = await HttpApi.PostAsync(new LogoutRequest().MakeApiRoute(), JsonContent.Create(new { })); + var result = await HttpApi.PostEmptyJsonAsync(new LogoutRequest().MakeApiRoute(), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)); var accessToken = result.GetCookie(AuthenticationConstants.Cookies.Token); var refreshToken = result.GetCookie(AuthenticationConstants.Cookies.RefreshToken); @@ -100,7 +107,8 @@ public async Task WhenLogoutAndAuthenticated_ThenReturnsWithNoCookies() public async Task WhenAccessSecureApiAndNotAuthenticated_ThenReturnsError() { #if TESTINGONLY - var result = await HttpApi.GetAsync(new GetCallerWithTokenOrAPIKeyTestingOnlyRequest().MakeApiRoute()); + var result = await HttpApi.GetAsync(new GetCallerWithTokenOrAPIKeyTestingOnlyRequest().MakeApiRoute(), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)); result.StatusCode.Should().Be(HttpStatusCode.Unauthorized); #endif @@ -109,10 +117,11 @@ public async Task WhenAccessSecureApiAndNotAuthenticated_ThenReturnsError() [Fact] public async Task WhenAccessSecureApiAndAuthenticated_ThenReturnsResponse() { - var (userId, _) = await HttpApi.LoginUserFromBrowserAsync(_jsonOptions); + var (userId, _) = await HttpApi.LoginUserFromBrowserAsync(_jsonOptions, _csrfService); #if TESTINGONLY - var result = await HttpApi.GetAsync(new GetCallerWithTokenOrAPIKeyTestingOnlyRequest().MakeApiRoute()); + var result = await HttpApi.GetAsync(new GetCallerWithTokenOrAPIKeyTestingOnlyRequest().MakeApiRoute(), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)); result.StatusCode.Should().Be(HttpStatusCode.OK); var callerId = (await result.Content.ReadFromJsonAsync(_jsonOptions))!.CallerId; diff --git a/src/Infrastructure.Web.Website.IntegrationTests/CSRFSpec.cs b/src/Infrastructure.Web.Website.IntegrationTests/CSRFSpec.cs new file mode 100644 index 00000000..743fbe81 --- /dev/null +++ b/src/Infrastructure.Web.Website.IntegrationTests/CSRFSpec.cs @@ -0,0 +1,312 @@ +using System.Net; +using System.Text.Json; +using Common.Extensions; +using FluentAssertions; +using HtmlAgilityPack; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.TestingOnly; +using Infrastructure.Web.Hosting.Common; +using Infrastructure.Web.Hosting.Common.Pipeline; +using IntegrationTesting.WebApi.Common; +using JetBrains.Annotations; +using WebsiteHost; +using Xunit; + +namespace Infrastructure.Web.Website.IntegrationTests; + +[UsedImplicitly] +public class CSRFSpec +{ + [Trait("Category", "Integration.Web")] + [Collection("API")] + public class GivenNoContext : WebApiSpec + { + public GivenNoContext(WebApiSetup setup) : base(setup) + { + StartupServer(); + var csrfService = setup.GetRequiredService(); +#if TESTINGONLY + HttpApi.PostEmptyJsonAsync(new DestroyAllRepositoriesRequest().MakeApiRoute(), + (message, cookies) => message.WithCSRF(cookies, csrfService)).GetAwaiter() + .GetResult(); +#endif + } + + [Fact] + public async Task WhenRequestWebRoot_ThenReturnsIndexHtml() + { + var result = await HttpApi.GetAsync("/"); + + var content = await result.Content.ReadAsStringAsync(); + content.Should().Contain(" tag with the name: {0}".Format(CSRFConstants.Html + .CSRFRequestFieldName)); + csrfTokenMetaTag?.Attributes["content"].Value.Should().NotBeNull( + "The tag named '{0}' should have been replaced with a real token".Format(CSRFConstants.Html + .CSRFRequestFieldName)); + csrfTokenMetaTag?.Attributes["content"].Value.Should().NotBe(CSRFConstants.Html.CSRFTokenPlaceholder, + "The tag named '{0}' should have been replaced with a real token".Format(CSRFConstants.Html + .CSRFRequestFieldName)); + } + } + + [Trait("Category", "Integration.Web")] + [Collection("API")] + public class GivenAnInsecureGetRequest : WebApiSpec + { + private readonly CSRFMiddleware.ICSRFService _csrfService; + private readonly JsonSerializerOptions _jsonOptions; + + public GivenAnInsecureGetRequest(WebApiSetup setup) : base(setup) + { + StartupServer(); + _csrfService = setup.GetRequiredService(); +#if TESTINGONLY + HttpApi.PostEmptyJsonAsync(new DestroyAllRepositoriesRequest().MakeApiRoute(), + (message, cookies) => message.WithCSRF(cookies, _csrfService)).GetAwaiter() + .GetResult(); +#endif + _jsonOptions = setup.GetRequiredService(); + } + + [Fact] + public async Task WhenRequestedWithNoCSRFToken_ThenSucceeds() + { +#if TESTINGONLY + var result = await HttpApi.GetAsync(new GetInsecureTestingOnlyRequest().MakeApiRoute()); + + result.StatusCode.Should().Be(HttpStatusCode.OK); +#endif + } + + [Fact] + public async Task WhenRequestedForAnonymousWithCSRFToken_ThenSucceeds() + { +#if TESTINGONLY + var result = await HttpApi.GetAsync(new GetInsecureTestingOnlyRequest().MakeApiRoute(), + (message, cookies) => message.WithCSRF(cookies, _csrfService)); + + result.StatusCode.Should().Be(HttpStatusCode.OK); +#endif + } + + [Fact] + public async Task WhenRequestedForUserWithCSRFToken_ThenSucceeds() + { + var (userId, _) = await HttpApi.LoginUserFromBrowserAsync(_jsonOptions, _csrfService); + +#if TESTINGONLY + var result = await HttpApi.GetAsync(new GetInsecureTestingOnlyRequest().MakeApiRoute(), + (message, cookies) => message.WithCSRF(cookies, _csrfService, userId)); + + result.StatusCode.Should().Be(HttpStatusCode.OK); +#endif + } + } + + [Trait("Category", "Integration.Web")] + [Collection("API")] + public class GivenAnInsecurePostRequestByAnonymousUser : WebApiSpec + { + private readonly CSRFMiddleware.ICSRFService _csrfService; + private readonly JsonSerializerOptions _jsonOptions; + + public GivenAnInsecurePostRequestByAnonymousUser(WebApiSetup setup) : base(setup) + { + StartupServer(); + _csrfService = setup.GetRequiredService(); +#if TESTINGONLY + HttpApi.PostEmptyJsonAsync(new DestroyAllRepositoriesRequest().MakeApiRoute(), + (message, cookies) => message.WithCSRF(cookies, _csrfService)).GetAwaiter() + .GetResult(); +#endif + _jsonOptions = setup.GetRequiredService(); + } + + [Fact] + public async Task WhenRequestedWithNoCSRFToken_ThenForbidden() + { +#if TESTINGONLY + var result = await HttpApi.PostEmptyJsonAsync(new PostInsecureTestingOnlyRequest().MakeApiRoute()); + + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var problem = await result.AsProblemAsync(_jsonOptions); + problem!.Detail.Should().Be(Resources.CSRFMiddleware_MissingCSRFHeaderValue); +#endif + } + + [Fact] + public async Task WhenRequestedCSRFToken_ThenSucceeds() + { +#if TESTINGONLY + var result = await HttpApi.PostEmptyJsonAsync(new PostInsecureTestingOnlyRequest().MakeApiRoute(), + (message, cookies) => message.WithCSRF(cookies, _csrfService)); + + result.StatusCode.Should().Be(HttpStatusCode.OK); +#endif + } + + [Fact] + public async Task WhenRequestedWithMismatchedCookieAndHeaderForAnonymous_ThenSucceeds() + { +#if TESTINGONLY + var result = await HttpApi.PostEmptyJsonAsync(new PostInsecureTestingOnlyRequest().MakeApiRoute(), + (message, cookies) => + { + var anonymous1 = _csrfService.CreateTokens(null); + var anonymous2 = _csrfService.CreateTokens(null); + + message.WithCSRF(cookies, anonymous1.Token, anonymous2.Signature); + }); + + result.StatusCode.Should().Be(HttpStatusCode.OK); +#endif + } + + [Fact] + public async Task WhenRequestedWithAnonymousHeaderAndUserCookie_ThenForbidden() + { +#if TESTINGONLY + var result = await HttpApi.PostEmptyJsonAsync(new PostInsecureTestingOnlyRequest().MakeApiRoute(), + (message, cookies) => + { + var anonymous = _csrfService.CreateTokens(null); + var user = _csrfService.CreateTokens("auserid"); + + message.WithCSRF(cookies, anonymous.Token, user.Signature); + }); + + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var problem = await result.AsProblemAsync(_jsonOptions); + problem!.Detail.Should().Be(Resources.CSRFMiddleware_InvalidSignature.Format("None")); +#endif + } + + [Fact] + public async Task WhenRequestedWithUserHeaderAndAnonymousCookie_ThenForbidden() + { +#if TESTINGONLY + var result = await HttpApi.PostEmptyJsonAsync(new PostInsecureTestingOnlyRequest().MakeApiRoute(), + (message, cookies) => + { + var anonymous = _csrfService.CreateTokens(null); + var user = _csrfService.CreateTokens("auserid"); + + message.WithCSRF(cookies, user.Token, anonymous.Signature); + }); + + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var problem = await result.AsProblemAsync(_jsonOptions); + problem!.Detail.Should().Be(Resources.CSRFMiddleware_InvalidSignature.Format("None")); +#endif + } + + [Fact] + public async Task WhenRequestedWithUserCSRFToken_ThenForbidden() + { +#if TESTINGONLY + var result = await HttpApi.PostEmptyJsonAsync(new PostInsecureTestingOnlyRequest().MakeApiRoute(), + (message, cookies) => message.WithCSRF(cookies, _csrfService, "auserid")); + + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var problem = await result.AsProblemAsync(_jsonOptions); + problem!.Detail.Should().Be(Resources.CSRFMiddleware_InvalidSignature.Format("None")); +#endif + } + } + + [Trait("Category", "Integration.Web")] + [Collection("API")] + public class GivenAnInsecurePostRequestByAuthenticatedUser : WebApiSpec + { + private readonly CSRFMiddleware.ICSRFService _csrfService; + private readonly JsonSerializerOptions _jsonOptions; + private readonly string _userId; + + public GivenAnInsecurePostRequestByAuthenticatedUser(WebApiSetup setup) : base(setup) + { + StartupServer(); + _csrfService = setup.GetRequiredService(); +#if TESTINGONLY + HttpApi.PostEmptyJsonAsync(new DestroyAllRepositoriesRequest().MakeApiRoute(), + (message, cookies) => message.WithCSRF(cookies, _csrfService)).GetAwaiter() + .GetResult(); +#endif + _jsonOptions = setup.GetRequiredService(); + + var (userId, _) = HttpApi.LoginUserFromBrowserAsync(_jsonOptions, _csrfService).GetAwaiter().GetResult(); + _userId = userId; + } + + [Fact] + public async Task WhenRequestedWithAnonymousCSRFToken_ThenForbidden() + { +#if TESTINGONLY + var result = await HttpApi.PostEmptyJsonAsync(new PostInsecureTestingOnlyRequest().MakeApiRoute(), + (message, cookies) => message.WithCSRF(cookies, _csrfService)); + + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var problem = await result.AsProblemAsync(_jsonOptions); + problem!.Detail.Should().Be(Resources.CSRFMiddleware_InvalidSignature.Format(_userId)); +#endif + } + + [Fact] + public async Task WhenRequestedWithUsersCSRFToken_ThenSucceeds() + { +#if TESTINGONLY + var result = await HttpApi.PostEmptyJsonAsync(new PostInsecureTestingOnlyRequest().MakeApiRoute(), + (message, cookies) => message.WithCSRF(cookies, _csrfService, _userId)); + + result.StatusCode.Should().Be(HttpStatusCode.OK); +#endif + } + + [Fact] + public async Task WhenRequestedWithMismatchedCookieAndHeaderForDifferentUsers_ThenForbidden() + { +#if TESTINGONLY + var result = await HttpApi.PostEmptyJsonAsync(new PostInsecureTestingOnlyRequest().MakeApiRoute(), + (message, cookies) => + { + var user1 = _csrfService.CreateTokens(_userId); + var user2 = _csrfService.CreateTokens("anotheruserid"); + + message.WithCSRF(cookies, user1.Token, user2.Signature); + }); + + result.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var problem = await result.AsProblemAsync(_jsonOptions); + problem!.Detail.Should().Be(Resources.CSRFMiddleware_InvalidSignature.Format(_userId)); +#endif + } + + [Fact] + public async Task WhenRequestedWithMismatchedCookieAndHeaderForSameUser_ThenSucceeds() + { +#if TESTINGONLY + var result = await HttpApi.PostEmptyJsonAsync(new PostInsecureTestingOnlyRequest().MakeApiRoute(), + (message, cookies) => + { + var user1 = _csrfService.CreateTokens(_userId); + var user2 = _csrfService.CreateTokens(_userId); + + message.WithCSRF(cookies, user1.Token, user2.Signature); + }); + + result.StatusCode.Should().Be(HttpStatusCode.OK); +#endif + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.IntegrationTests/Infrastructure.Web.Website.IntegrationTests.csproj b/src/Infrastructure.Web.Website.IntegrationTests/Infrastructure.Web.Website.IntegrationTests.csproj index edf1bfdf..ece0ef9e 100644 --- a/src/Infrastructure.Web.Website.IntegrationTests/Infrastructure.Web.Website.IntegrationTests.csproj +++ b/src/Infrastructure.Web.Website.IntegrationTests/Infrastructure.Web.Website.IntegrationTests.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Infrastructure.Web.Website.IntegrationTests/RecordingApiSpec.cs b/src/Infrastructure.Web.Website.IntegrationTests/RecordingApiSpec.cs index b89bc93f..acb04766 100644 --- a/src/Infrastructure.Web.Website.IntegrationTests/RecordingApiSpec.cs +++ b/src/Infrastructure.Web.Website.IntegrationTests/RecordingApiSpec.cs @@ -1,3 +1,4 @@ +using System.Net.Http.Json; using System.Text.Json; using Application.Interfaces; using Application.Resources.Shared; @@ -6,6 +7,8 @@ using FluentAssertions; using Infrastructure.Hosting.Common.Extensions; using Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; +using Infrastructure.Web.Api.Operations.Shared.TestingOnly; +using Infrastructure.Web.Hosting.Common.Pipeline; using IntegrationTesting.WebApi.Common; using IntegrationTesting.WebApi.Common.Stubs; using Microsoft.Extensions.DependencyInjection; @@ -19,11 +22,18 @@ namespace Infrastructure.Web.Website.IntegrationTests; [Collection("API")] public class RecordingApiSpec : WebApiSpec { + private readonly CSRFMiddleware.ICSRFService _csrfService; private readonly StubRecorder _recorder; public RecordingApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) { - EmptyAllRepositories(); + StartupServer(); + _csrfService = setup.GetRequiredService(); +#if TESTINGONLY + HttpApi.PostEmptyJsonAsync(new DestroyAllRepositoriesRequest().MakeApiRoute(), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)).GetAwaiter() + .GetResult(); +#endif _recorder = setup.GetRequiredService().As(); _recorder.Reset(); } @@ -31,10 +41,12 @@ public RecordingApiSpec(WebApiSetup setup) : base(setup, OverrideDepend [Fact] public async Task WhenRecordPageView_ThenRecordsUsage() { - await Api.PostAsync(new RecordPageViewRequest + var request = new RecordPageViewRequest { Path = "apath" - }); + }; + await HttpApi.PostAsync(request.MakeApiRoute(), JsonContent.Create(request), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)); _recorder.LastUsageEventName.Should().Be(UsageConstants.Events.Web.WebPageVisit); _recorder.LastUsageAdditional!.Count.Should().Be(6); @@ -51,14 +63,16 @@ await Api.PostAsync(new RecordPageViewRequest [Fact] public async Task WhenRecordUsage_ThenRecordsUsage() { - await Api.PostAsync(new RecordUseRequest + var request = new RecordUseRequest { EventName = "aneventname", Additional = new Dictionary { { "aname", "avalue" } } - }); + }; + await HttpApi.PostAsync(request.MakeApiRoute(), JsonContent.Create(request), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)); _recorder.LastUsageEventName.Should().Be("aneventname"); _recorder.LastUsageAdditional!.Count.Should().Be(6); @@ -75,10 +89,12 @@ await Api.PostAsync(new RecordUseRequest [Fact] public async Task WhenRecordCrash_ThenRecordsCrash() { - await Api.PostAsync(new RecordCrashRequest + var request = new RecordCrashRequest { Message = "amessage" - }); + }; + await HttpApi.PostAsync(request.MakeApiRoute(), JsonContent.Create(request), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)); _recorder.LastCrashLevel.Should().Be(CrashLevel.Critical); _recorder.LastCrashException.Should().BeOfType(); @@ -88,7 +104,7 @@ await Api.PostAsync(new RecordCrashRequest [Fact] public async Task WhenRecordTrace_ThenRecordsTrace() { - await Api.PostAsync(new RecordTraceRequest + var request = new RecordTraceRequest { Level = RecorderTraceLevel.Warning.ToString(), MessageTemplate = "amessage {aparam}", @@ -96,7 +112,9 @@ await Api.PostAsync(new RecordTraceRequest { "avalue" } - }); + }; + await HttpApi.PostAsync(request.MakeApiRoute(), JsonContent.Create(request), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)); _recorder.LastTraceLevel.Should().Be(StubRecorderTraceLevel.Warning); _recorder.LastTraceMessageTemplate.Should().Be("amessage {aparam}"); @@ -106,14 +124,16 @@ await Api.PostAsync(new RecordTraceRequest [Fact] public async Task WhenRecordMeasurement_ThenRecordsMeasurement() { - await Api.PostAsync(new RecordMeasureRequest + var request = new RecordMeasureRequest { EventName = "aneventname", Additional = new Dictionary { { "aname", "avalue" } } - }); + }; + await HttpApi.PostAsync(request.MakeApiRoute(), JsonContent.Create(request), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)); _recorder.LastMeasureEventName.Should().Be("aneventname"); _recorder.LastMeasureAdditional!.Count.Should().Be(6); diff --git a/src/Infrastructure.Web.Website.IntegrationTests/ReverseProxyApiSpec.cs b/src/Infrastructure.Web.Website.IntegrationTests/ReverseProxyApiSpec.cs index 8814d184..7d80a2b3 100644 --- a/src/Infrastructure.Web.Website.IntegrationTests/ReverseProxyApiSpec.cs +++ b/src/Infrastructure.Web.Website.IntegrationTests/ReverseProxyApiSpec.cs @@ -5,6 +5,7 @@ using Infrastructure.Web.Api.Operations.Shared.Health; using Infrastructure.Web.Api.Operations.Shared.TestingOnly; using Infrastructure.Web.Common; +using Infrastructure.Web.Hosting.Common.Pipeline; using IntegrationTesting.WebApi.Common; using Microsoft.Extensions.DependencyInjection; using WebsiteHost; @@ -16,13 +17,16 @@ namespace Infrastructure.Web.Website.IntegrationTests; [Collection("API")] public class ReverseProxyApiSpec : WebApiSpec { + private readonly CSRFMiddleware.ICSRFService _csrfService; private readonly JsonSerializerOptions _jsonOptions; public ReverseProxyApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) { StartupServer(); + _csrfService = setup.GetRequiredService(); #if TESTINGONLY - HttpApi.PostAsync(new DestroyAllRepositoriesRequest().MakeApiRoute(), JsonContent.Create(new { })).GetAwaiter() + HttpApi.PostEmptyJsonAsync(new DestroyAllRepositoriesRequest().MakeApiRoute(), + (msg, cookies) => msg.WithCSRF(cookies, _csrfService)).GetAwaiter() .GetResult(); #endif _jsonOptions = setup.GetRequiredService(); @@ -66,7 +70,7 @@ public async Task WhenRequestARemoteWebApiAndNotExists_ThenReturnsNotFound() public async Task WhenRequestAnAnonymousRemoteWebApi_ThenReverseProxies() { #if TESTINGONLY - var result = await HttpApi.GetAsync(new AuthorizeByNothingTestingOnlyRequest().MakeApiRoute()); + var result = await HttpApi.GetAsync(new GetInsecureTestingOnlyRequest().MakeApiRoute()); result.StatusCode.Should().Be(HttpStatusCode.OK); #endif @@ -85,7 +89,7 @@ public async Task WhenRequestASecureRemoteWebApiAndNotAuthenticated_ThenReturnsU [Fact] public async Task WhenRequestASecureRemoteWebApiAndAuthenticated_ThenReturnsResponse() { - var (userId, _) = await HttpApi.LoginUserFromBrowserAsync(_jsonOptions); + var (userId, _) = await HttpApi.LoginUserFromBrowserAsync(_jsonOptions, _csrfService); #if TESTINGONLY var result = await HttpApi.GetAsync(new GetCallerWithTokenOrAPIKeyTestingOnlyRequest().MakeApiRoute()); diff --git a/src/Infrastructure.Web.Website.IntegrationTests/WebsiteTestingExtensions.cs b/src/Infrastructure.Web.Website.IntegrationTests/WebsiteTestingExtensions.cs index 15bd50b9..7bb32692 100644 --- a/src/Infrastructure.Web.Website.IntegrationTests/WebsiteTestingExtensions.cs +++ b/src/Infrastructure.Web.Website.IntegrationTests/WebsiteTestingExtensions.cs @@ -1,13 +1,17 @@ +using System.Net; using System.Net.Http.Json; using System.Text.Json; using Common; using Common.Extensions; using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common; using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Api.Interfaces; using Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; using Infrastructure.Web.Api.Operations.Shared.Identities; using Infrastructure.Web.Common; +using Infrastructure.Web.Hosting.Common.Pipeline; +using IntegrationTesting.WebApi.Common; using AuthenticateResponse = Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd.AuthenticateResponse; namespace Infrastructure.Web.Website.IntegrationTests; @@ -15,9 +19,10 @@ namespace Infrastructure.Web.Website.IntegrationTests; public static class WebsiteTestingExtensions { public static async Task<(string UserId, HttpResponseMessage Response)> AuthenticateUserFromBrowserAsync( - this HttpClient websiteClient, JsonSerializerOptions jsonOptions, string emailAddress, string password) + this IHttpClient websiteClient, JsonSerializerOptions jsonOptions, CSRFMiddleware.ICSRFService csrfService, + string emailAddress, string password) { - // This call should set the cookies up + // This call should populate the auth cookies var authenticateRequest = new AuthenticateRequest { Provider = AuthenticationConstants.Providers.Credentials, @@ -25,7 +30,8 @@ public static class WebsiteTestingExtensions Password = password }; var authenticateUrl = authenticateRequest.MakeApiRoute(); - var authenticated = await websiteClient.PostAsync(authenticateUrl, JsonContent.Create(authenticateRequest)); + var authenticated = await websiteClient.PostAsync(authenticateUrl, JsonContent.Create(authenticateRequest), + (msg, cookies) => msg.WithCSRF(cookies, csrfService)); var authTokens = (await authenticated.Content.ReadFromJsonAsync(jsonOptions))!; return (authTokens.UserId, authenticated)!; @@ -33,9 +39,7 @@ public static class WebsiteTestingExtensions public static Optional GetCookie(this HttpResponseMessage responseMessage, string name) { - var cookies = responseMessage.Headers.GetValues("Set-Cookie") - .ToList(); - if (cookies.HasNone()) + if (!responseMessage.Headers.TryGetValues(HttpHeaders.SetCookie, out var cookies)) { return Optional.None; } @@ -60,15 +64,14 @@ public static Optional GetCookie(this HttpResponseMessage responseMessag } public static async Task<(string UserId, HttpResponseMessage Response)> LoginUserFromBrowserAsync( - this HttpClient websiteClient, - JsonSerializerOptions jsonOptions) + this IHttpClient websiteClient, JsonSerializerOptions jsonOptions, CSRFMiddleware.ICSRFService csrfService) { const string emailAddress = "auser@company.com"; const string password = "1Password!"; - await RegisterPersonUserFromBrowserAsync(websiteClient, jsonOptions, emailAddress, password); + await RegisterPersonUserFromBrowserAsync(websiteClient, jsonOptions, csrfService, emailAddress, password); - return await AuthenticateUserFromBrowserAsync(websiteClient, jsonOptions, emailAddress, password); + return await AuthenticateUserFromBrowserAsync(websiteClient, jsonOptions, csrfService, emailAddress, password); } public static string MakeApiRoute(this IWebRequest request) @@ -76,8 +79,9 @@ public static string MakeApiRoute(this IWebRequest request) return $"{WebConstants.BackEndForFrontEndBasePath}{request.GetRequestInfo().Route}"; } - public static async Task RegisterPersonUserFromBrowserAsync(this HttpClient websiteClient, - JsonSerializerOptions jsonOptions, string emailAddress, string password) + public static async Task RegisterPersonUserFromBrowserAsync(this IHttpClient websiteClient, + JsonSerializerOptions jsonOptions, CSRFMiddleware.ICSRFService csrfService, string emailAddress, + string password) { var registrationRequest = new RegisterPersonPasswordRequest { @@ -88,7 +92,8 @@ public static async Task RegisterPersonUserFromBrowserAsync(this HttpCli TermsAndConditionsAccepted = true }; var registrationUrl = registrationRequest.MakeApiRoute(); - var person = await websiteClient.PostAsync(registrationUrl, JsonContent.Create(registrationRequest)); + var person = await websiteClient.PostAsync(registrationUrl, JsonContent.Create(registrationRequest), + (msg, cookies) => msg.WithCSRF(cookies, csrfService)); var userId = (await person.Content.ReadFromJsonAsync(jsonOptions))! .Credential!.User.Id; @@ -99,7 +104,8 @@ public static async Task RegisterPersonUserFromBrowserAsync(this HttpCli UserId = userId }; var getTokenUrl = getTokenRequest.MakeApiRoute(); - var confirmationToken = await websiteClient.GetAsync(getTokenUrl); + var confirmationToken = await websiteClient.GetAsync(getTokenUrl, + (msg, cookies) => msg.WithCSRF(cookies, csrfService)); var token = (await confirmationToken.Content.ReadFromJsonAsync(jsonOptions))! .Token; @@ -108,8 +114,28 @@ public static async Task RegisterPersonUserFromBrowserAsync(this HttpCli Token = token! }; var confirmationUrl = confirmationRequest.MakeApiRoute(); - await websiteClient.PostAsync(confirmationUrl, JsonContent.Create(confirmationRequest)); + await websiteClient.PostAsync(confirmationUrl, JsonContent.Create(confirmationRequest), + (msg, cookies) => msg.WithCSRF(cookies, csrfService)); #endif return userId; } + + public static void WithCSRF(this HttpRequestMessage message, CookieContainer cookies, + CSRFMiddleware.ICSRFService csrfService, + string? userId = null) + { + var tokens = csrfService.CreateTokens(userId); + + WithCSRF(message, cookies, tokens.Token, tokens.Signature); + } + + public static void WithCSRF(this HttpRequestMessage message, CookieContainer cookies, + string token, string signature) + { + var host = message.RequestUri!.Host; + cookies.Add(new Cookie(CSRFConstants.Cookies.AntiCSRF, signature, "/", host)); + message.Headers.Add(CSRFConstants.Headers.AntiCSRF, token); + var origin = $"{message.RequestUri.Scheme}{Uri.SchemeDelimiter}{message.RequestUri.Authority}"; + message.Headers.Add(HttpHeaders.Origin, origin); + } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticationApiSpec.cs b/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticationApiSpec.cs new file mode 100644 index 00000000..5ca22695 --- /dev/null +++ b/src/Infrastructure.Web.Website.UnitTests/Api/AuthN/AuthenticationApiSpec.cs @@ -0,0 +1,135 @@ +using Application.Interfaces; +using Application.Resources.Shared; +using Common; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.BackEndForFrontEnd; +using Microsoft.AspNetCore.Http; +using Moq; +using WebsiteHost.Api.AuthN; +using WebsiteHost.Application; +using Xunit; + +namespace Infrastructure.Web.Website.UnitTests.Api.AuthN; + +[Trait("Category", "Unit")] +public class AuthenticationApiSpec +{ + private readonly AuthenticationApi _api; + private readonly Mock _application; + private readonly Mock _caller; + private readonly Mock _httpRequestCookies; + private readonly Mock _httpResponseCookies; + + public AuthenticationApiSpec() + { + _application = new Mock(); + _caller = new Mock(); + var contextFactory = new Mock(); + contextFactory.Setup(ccf => ccf.Create()) + .Returns(_caller.Object); + var httpRequest = new Mock(); + _httpRequestCookies = new Mock(); + httpRequest.Setup(req => req.Cookies).Returns(_httpRequestCookies.Object); + var httpResponse = new Mock(); + _httpResponseCookies = new Mock(); + httpResponse.Setup(res => res.Cookies).Returns(_httpResponseCookies.Object); + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(hca => hca.HttpContext!.Request) + .Returns(httpRequest.Object); + httpContextAccessor.Setup(hca => hca.HttpContext!.Response) + .Returns(httpResponse.Object); + _api = new AuthenticationApi(contextFactory.Object, _application.Object, httpContextAccessor.Object); + } + + [Fact] + public async Task WhenLogout_ThenDeletesCookies() + { + _application.Setup(app => app.LogoutAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Ok); + + await _api.Logout(new LogoutRequest(), CancellationToken.None); + + _application.Verify(app => app.LogoutAsync(_caller.Object, It.IsAny())); + _httpResponseCookies.Verify(c => + c.Delete(AuthenticationConstants.Cookies.Token)); + _httpResponseCookies.Verify(c => + c.Delete(AuthenticationConstants.Cookies.RefreshToken)); + } + + [Fact] + public async Task WhenAuthenticate_ThenSetsCookies() + { + _application.Setup(app => app.AuthenticateAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AuthenticateTokens + { + AccessToken = "anaccesstoken", + RefreshToken = "arefreshtoken", + UserId = "auserid", + ExpiresOn = DateTime.UtcNow + }); + + await _api.Authenticate(new AuthenticateRequest + { + Provider = "aprovider", + Username = "ausername", + Password = "apassword" + }, CancellationToken.None); + + _application.Verify(app => app.AuthenticateAsync(_caller.Object, "aprovider", null, "ausername", "apassword", + It.IsAny())); + _httpResponseCookies.Verify(c => + c.Append(AuthenticationConstants.Cookies.Token, "anaccesstoken", It.IsAny())); + _httpResponseCookies.Verify(c => + c.Append(AuthenticationConstants.Cookies.RefreshToken, "arefreshtoken", It.IsAny())); + } + + [Fact] + public async Task WhenRefreshAndCookieNotExists_ThenReturnsError() + { + _httpRequestCookies.Setup(c => c.TryGetValue(AuthenticationConstants.Cookies.Token, out It.Ref.IsAny)) + .Returns(false); + + _application.Setup(app => app.RefreshTokenAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(Error.NotAuthenticated()); + + await _api.RefreshToken(new RefreshTokenRequest(), CancellationToken.None); + + _application.Verify( + app => app.RefreshTokenAsync(_caller.Object, null, It.IsAny())); + _httpResponseCookies.Verify(c => + c.Append(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenRefreshAndCookieExists_ThenSetsCookies() + { + _httpRequestCookies.Setup(c => + c.TryGetValue(AuthenticationConstants.Cookies.RefreshToken, out It.Ref.IsAny)) + .Returns((string _, ref string? value) => + { + value = "arefreshtoken"; + return true; + }); + _application.Setup(app => app.RefreshTokenAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new AuthenticateTokens + { + AccessToken = "anaccesstoken", + RefreshToken = "arefreshtoken", + UserId = "auserid", + ExpiresOn = DateTime.UtcNow + }); + + await _api.RefreshToken(new RefreshTokenRequest(), CancellationToken.None); + + _application.Verify( + app => app.RefreshTokenAsync(_caller.Object, "arefreshtoken", It.IsAny())); + _httpResponseCookies.Verify(c => + c.Append(AuthenticationConstants.Cookies.Token, "anaccesstoken", It.IsAny())); + _httpResponseCookies.Verify(c => + c.Append(AuthenticationConstants.Cookies.RefreshToken, "arefreshtoken", It.IsAny())); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs b/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs index 85e375b2..990998b5 100644 --- a/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs +++ b/src/Infrastructure.Web.Website.UnitTests/Application/AuthenticationApplicationSpec.cs @@ -4,7 +4,6 @@ using Infrastructure.Interfaces; using Infrastructure.Web.Api.Operations.Shared.Identities; using Infrastructure.Web.Interfaces.Clients; -using Microsoft.AspNetCore.Http; using Moq; using UnitTesting.Common; using WebsiteHost.Application; @@ -17,8 +16,6 @@ public class AuthenticationApplicationSpec { private readonly AuthenticationApplication _application; private readonly Mock _caller; - private readonly Mock _httpRequestCookies; - private readonly Mock _httpResponseCookies; private readonly Mock _recorder; private readonly Mock _serviceClient; @@ -27,38 +24,25 @@ public AuthenticationApplicationSpec() _recorder = new Mock(); _caller = new Mock(); _serviceClient = new Mock(); - var httpContextAccessor = new Mock(); - var httpRequest = new Mock(); - _httpRequestCookies = new Mock(); - httpRequest.Setup(req => req.Cookies).Returns(_httpRequestCookies.Object); - var httpResponse = new Mock(); - _httpResponseCookies = new Mock(); - httpResponse.Setup(res => res.Cookies).Returns(_httpResponseCookies.Object); - httpContextAccessor.Setup(hca => hca.HttpContext!.Request) - .Returns(httpRequest.Object); - httpContextAccessor.Setup(hca => hca.HttpContext!.Response) - .Returns(httpResponse.Object); _application = - new AuthenticationApplication(_recorder.Object, httpContextAccessor.Object, _serviceClient.Object); + new AuthenticationApplication(_recorder.Object, _serviceClient.Object); } [Fact] - public async Task WhenLogout_ThenDeletesCookies() + public async Task WhenLogout_ThenLogsOut() { - await _application.LogoutAsync(_caller.Object, CancellationToken.None); + var result = await _application.LogoutAsync(_caller.Object, CancellationToken.None); - _httpResponseCookies.Verify(c => - c.Delete(AuthenticationConstants.Cookies.Token)); - _httpResponseCookies.Verify(c => - c.Delete(AuthenticationConstants.Cookies.RefreshToken)); + result.Should().BeSuccess(); _recorder.Verify(rec => rec.TrackUsage(It.IsAny(), UsageConstants.Events.UsageScenarios.UserLogout, null)); } [Fact] - public async Task WhenAuthenticateWithCredentials_ThenAuthenticatesAndSetsCookies() + public async Task WhenAuthenticateWithCredentials_ThenAuthenticates() { + var expires = DateTime.UtcNow; _serviceClient.Setup(sc => sc.PostAsync(It.IsAny(), It.IsAny(), null, It.IsAny())) .Returns(Task.FromResult>(new AuthenticateResponse @@ -66,30 +50,28 @@ public async Task WhenAuthenticateWithCredentials_ThenAuthenticatesAndSetsCookie UserId = "auserid", AccessToken = "anaccesstoken", RefreshToken = "arefreshtoken", - ExpiresOnUtc = DateTime.UtcNow + ExpiresOnUtc = expires })); var result = await _application.AuthenticateAsync(_caller.Object, AuthenticationConstants.Providers.Credentials, null, "ausername", "apassword", CancellationToken.None); - result.Value.UserId.Should().Be("auserid"); result.Value.AccessToken.Should().Be("anaccesstoken"); result.Value.RefreshToken.Should().Be("arefreshtoken"); + result.Value.ExpiresOn.Should().Be(expires); + result.Value.UserId.Should().Be("auserid"); _serviceClient.Verify(sc => sc.PostAsync(_caller.Object, It.Is(req => req.Username == "ausername" && req.Password == "apassword" ), null, It.IsAny())); - _httpResponseCookies.Verify(c => - c.Append(AuthenticationConstants.Cookies.Token, "anaccesstoken", It.IsAny())); - _httpResponseCookies.Verify(c => - c.Append(AuthenticationConstants.Cookies.RefreshToken, "arefreshtoken", It.IsAny())); _recorder.Verify(rec => rec.TrackUsage(It.IsAny(), UsageConstants.Events.UsageScenarios.UserLogin, null)); } [Fact] - public async Task WhenAuthenticateWithSingleSignOn_ThenAuthenticatesAndSetsCookies() + public async Task WhenAuthenticateWithSingleSignOn_ThenAuthenticates() { + var expires = DateTime.UtcNow; _serviceClient.Setup(sc => sc.PostAsync(It.IsAny(), It.IsAny(), null, It.IsAny())) .Returns(Task.FromResult>(new AuthenticateResponse @@ -97,23 +79,20 @@ public async Task WhenAuthenticateWithSingleSignOn_ThenAuthenticatesAndSetsCooki UserId = "auserid", AccessToken = "anaccesstoken", RefreshToken = "arefreshtoken", - ExpiresOnUtc = DateTime.UtcNow + ExpiresOnUtc = expires })); var result = await _application.AuthenticateAsync(_caller.Object, AuthenticationConstants.Providers.SingleSignOn, "anauthcode", null, null, CancellationToken.None); - result.Value.UserId.Should().Be("auserid"); result.Value.AccessToken.Should().Be("anaccesstoken"); result.Value.RefreshToken.Should().Be("arefreshtoken"); + result.Value.ExpiresOn.Should().Be(expires); + result.Value.UserId.Should().Be("auserid"); _serviceClient.Verify(sc => sc.PostAsync(_caller.Object, It.Is(req => req.AuthCode == "anauthcode" ), null, It.IsAny())); - _httpResponseCookies.Verify(c => - c.Append(AuthenticationConstants.Cookies.Token, "anaccesstoken", It.IsAny())); - _httpResponseCookies.Verify(c => - c.Append(AuthenticationConstants.Cookies.RefreshToken, "arefreshtoken", It.IsAny())); _recorder.Verify(rec => rec.TrackUsage(It.IsAny(), UsageConstants.Events.UsageScenarios.UserLogin, null)); } @@ -121,12 +100,9 @@ public async Task WhenAuthenticateWithSingleSignOn_ThenAuthenticatesAndSetsCooki [Fact] public async Task WhenRefreshTokenAndCookieNotExist_ThenReturnsError() { - _httpRequestCookies.Setup(c => c.TryGetValue(AuthenticationConstants.Cookies.Token, out It.Ref.IsAny)) - .Returns(false); + var result = await _application.RefreshTokenAsync(_caller.Object, null, CancellationToken.None); - var result = await _application.RefreshTokenAsync(_caller.Object, CancellationToken.None); - - result.Should().BeError(ErrorCode.EntityNotFound); + result.Should().BeError(ErrorCode.NotAuthenticated); _serviceClient.Verify( sc => sc.PostAsync(_caller.Object, It.IsAny(), null, It.IsAny()), Times.Never); @@ -137,29 +113,23 @@ public async Task WhenRefreshTokenAndCookieNotExist_ThenReturnsError() [Fact] public async Task WhenRefreshTokenCookieExists_ThenRefreshesAndSetsCookie() { - _httpRequestCookies.Setup(c => - c.TryGetValue(AuthenticationConstants.Cookies.RefreshToken, out It.Ref.IsAny)) - .Returns((string _, ref string? value) => - { - value = "arefreshtoken"; - return true; - }); + var expires = DateTime.UtcNow; _serviceClient.Setup(sc => sc.PostAsync(It.IsAny(), It.IsAny(), null, It.IsAny())) .Returns(Task.FromResult>(new RefreshTokenResponse { AccessToken = "anaccesstoken", RefreshToken = "arefreshtoken", - ExpiresOnUtc = DateTime.UtcNow + ExpiresOnUtc = expires })); - var result = await _application.RefreshTokenAsync(_caller.Object, CancellationToken.None); + var result = await _application.RefreshTokenAsync(_caller.Object, "arefreshtoken", CancellationToken.None); result.Should().BeSuccess(); - _httpResponseCookies.Verify(c => - c.Append(AuthenticationConstants.Cookies.Token, "anaccesstoken", It.IsAny())); - _httpResponseCookies.Verify(c => - c.Append(AuthenticationConstants.Cookies.RefreshToken, "arefreshtoken", It.IsAny())); + result.Value.AccessToken.Should().Be("anaccesstoken"); + result.Value.RefreshToken.Should().Be("arefreshtoken"); + result.Value.ExpiresOn.Should().Be(expires); + result.Value.UserId.Should().BeNull(); _serviceClient.Verify( sc => sc.PostAsync(_caller.Object, It.Is(req => req.RefreshToken == "arefreshtoken" diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/appsettings.Testing.json b/src/Infrastructure.Worker.Api.IntegrationTests/appsettings.Testing.json index 7429f852..0dd865fb 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/appsettings.Testing.json +++ b/src/Infrastructure.Worker.Api.IntegrationTests/appsettings.Testing.json @@ -26,7 +26,7 @@ "Hosts": { "AncillaryApi": { "BaseUrl": "https://localhost:5001", - "HmacAuthNSecret": "asecret" + "HMACAuthNSecret": "asecret" }, "WebsiteHost": { "BaseUrl": "https://localhost:5101" diff --git a/src/IntegrationTesting.WebApi.Common/IHttpClient.cs b/src/IntegrationTesting.WebApi.Common/IHttpClient.cs new file mode 100644 index 00000000..26ebd06e --- /dev/null +++ b/src/IntegrationTesting.WebApi.Common/IHttpClient.cs @@ -0,0 +1,24 @@ +using System.Net; + +namespace IntegrationTesting.WebApi.Common; + +/// +/// Defines a HTTP client +/// +public interface IHttpClient +{ + Uri? BaseAddress { get; } + + Task GetAsync(string route, Action? requestFilter = null); + + Task GetStringAsync(string route, Action? requestFilter = null); + + Task PostAsync(string route, HttpContent content, + Action? requestFilter = null); + + Task PostEmptyJsonAsync(string route, + Action? requestFilter = null); + + Task SendAsync(HttpRequestMessage message, + Action? requestFilter = null); +} \ No newline at end of file diff --git a/src/IntegrationTesting.WebApi.Common/TestingClient.cs b/src/IntegrationTesting.WebApi.Common/TestingClient.cs new file mode 100644 index 00000000..4ccd0fef --- /dev/null +++ b/src/IntegrationTesting.WebApi.Common/TestingClient.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Common.Extensions; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Common.Extensions; +using Microsoft.AspNetCore.Mvc.Testing.Handlers; + +namespace IntegrationTesting.WebApi.Common; + +/// +/// Provides a used for testing +/// +public sealed class TestingClient : IHttpClient, IDisposable +{ + private readonly CookieContainerHandler _handler; + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _jsonOptions; + + public TestingClient(HttpClient httpClient, JsonSerializerOptions jsonOptions, CookieContainerHandler handler) + { + _httpClient = httpClient; + _jsonOptions = jsonOptions; + _handler = handler; + } + + public void Dispose() + { + _httpClient.Dispose(); + _handler.Dispose(); + } + + public async Task GetAsync(string route, + Action? requestFilter = null) + { + var message = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = new Uri(BaseAddress!, route) + }; + + return await SendAsync(message, requestFilter); + } + + public async Task GetStringAsync(string route, + Action? requestFilter = null) + { + var message = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = new Uri(BaseAddress!, route) + }; + + var response = await SendAsync(message, requestFilter); + + return await response.Content.ReadAsStringAsync(); + } + + public async Task PostAsync(string route, HttpContent content, + Action? requestFilter = null) + { + var message = new HttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri(BaseAddress!, route), + Content = content + }; + + return await SendAsync(message, requestFilter); + } + + public async Task PostEmptyJsonAsync(string route, + Action? requestFilter = null) + { + var message = new HttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri(BaseAddress!, route), + Content = JsonContent.Create(new { }) + }; + + return await SendAsync(message, requestFilter); + } + + public async Task SendAsync(HttpRequestMessage message, + Action? requestFilter = null) + { + if (requestFilter.Exists()) + { + requestFilter(message, _handler.Container); + } + + var response = await _httpClient.SendAsync(message); + if (response.StatusCode >= HttpStatusCode.InternalServerError) + { + throw await ToExceptionAsync(response, _jsonOptions); + } + + return response; + } + + public Uri? BaseAddress => _httpClient.BaseAddress; + + private static async Task ToExceptionAsync(HttpResponseMessage response, + JsonSerializerOptions jsonOptions) + { + var problem = await response.AsProblemAsync(jsonOptions); + if (problem.Exists()) + { + var details = problem.ToResponseProblem(); + throw details.ToException(); + } + + throw response.StatusCode.ToResponseProblem(response.ReasonPhrase) + .ToException(); + } +} \ No newline at end of file diff --git a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs index 211a3f88..47423f9f 100644 --- a/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs +++ b/src/IntegrationTesting.WebApi.Common/WebApiSpec.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Application.Resources.Shared; using Application.Services.Shared; +using Common; using Common.Extensions; using FluentAssertions; using Infrastructure.Web.Api.Operations.Shared.Identities; @@ -14,6 +15,7 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Mvc.Testing.Handlers; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -103,7 +105,7 @@ public abstract class WebApiSpec : IClassFixture>, IDi "run --no-build --configuration {0} --launch-profile {1} --project {2}"; private const string WebServerBaseUrlFormat = "https://localhost:{0}/"; protected readonly IHttpJsonClient Api; - protected readonly HttpClient HttpApi; + protected readonly IHttpClient HttpApi; protected readonly StubNotificationsService NotificationsService; private readonly List _additionalServerProcesses = new(); @@ -133,7 +135,7 @@ protected virtual void Dispose(bool disposing) { if (disposing) { - HttpApi.Dispose(); + (HttpApi as IDisposable)?.Dispose(); _setup.Dispose(); _additionalServerProcesses.ForEach(ShutdownProcess); } @@ -254,29 +256,22 @@ private static void ShutdownProcess(int processId) { if (processId != 0) { - try - { - var process = Process.GetProcessById(processId); - process.Kill(); - } - catch (ArgumentException) - { - //Ignore, the process does not exist - } + var process = Process.GetProcessById(processId); + Try.Safely(() => process.Kill()); } } - private static (IHttpJsonClient Api, HttpClient HttpApi) CreateClients(WebApiSetup host) + private static (IHttpJsonClient Api, IHttpClient HttpApi) CreateClients( + WebApiSetup host) where TAnotherHost : class { - var httpApi = host.CreateClient(new WebApplicationFactoryClientOptions - { - HandleCookies = true, - BaseAddress = new Uri(WebServerBaseUrlFormat.Format(GetNextAvailablePort())) - }); + var requestUri = new Uri(WebServerBaseUrlFormat.Format(GetNextAvailablePort())); + var handler = new CookieContainerHandler(); + var client = host.CreateDefaultClient(requestUri, handler); var jsonOptions = host.GetRequiredService(); - var api = new JsonClient(httpApi, jsonOptions); + var httpApi = new TestingClient(client, jsonOptions, handler); + var api = new JsonClient(client, jsonOptions); return (api, httpApi); } diff --git a/src/SaaStack.sln b/src/SaaStack.sln index 00a53a41..6df7db04 100644 --- a/src/SaaStack.sln +++ b/src/SaaStack.sln @@ -296,6 +296,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EndUsersInfrastructure.Inte EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools.Generators.Web.Api.Authorization.UnitTests", "Tools.Generators.Web.Api.Authorization.UnitTests\Tools.Generators.Web.Api.Authorization.UnitTests.csproj", "{E081B52F-A4AC-47A0-B03C-F23BF34CE1E7}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{D3B68FF7-293B-4458-B8D8-49D3DF59B495}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1044,8 +1046,6 @@ Global {FB2419FA-457F-406A-AADC-E6E44813896B} = {0358DED1-114C-4EFB-98C7-3D6B50A127DF} {22E40299-87EF-437F-ACDA-A9A0C1199AD9} = {F561CAF6-2A8C-4440-B12E-7753F25D9879} {9B420F01-63A5-4EEE-B6AC-09717BE179E9} = {4B1A213C-36A7-41A7-BFC7-B3CFF5795912} - {BC14CDD1-E127-4DF7-A1B3-55164CA8D1A4} = {19ADDB2F-B589-49EF-9BDA-BD9908057D60} - {11F60901-1E1C-4B1B-83E8-261269D2681B} = {19ADDB2F-B589-49EF-9BDA-BD9908057D60} {5DD98C48-F081-4CD1-9F01-1FF19323FC1E} = {3782A767-2274-4F44-80C6-D6C6EEB9C9A5} {1734A5D1-B2C8-4107-9DAA-E3F99F49ABEC} = {4B1A213C-36A7-41A7-BFC7-B3CFF5795912} {5630C518-92EB-482E-A547-99E80FBBD34D} = {8B850C5F-D5AB-4992-B343-6501A70ED801} @@ -1069,5 +1069,8 @@ Global {064B025A-7951-4706-B6A4-86BF6475239C} = {F2F759A4-6B5D-4E11-AFCC-679BF0E72AE6} {306F13C6-CC51-4956-BB88-54355BD05A42} = {F2F759A4-6B5D-4E11-AFCC-679BF0E72AE6} {E081B52F-A4AC-47A0-B03C-F23BF34CE1E7} = {A25A3BA8-5602-4825-9595-2CF96B166920} + {D3B68FF7-293B-4458-B8D8-49D3DF59B495} = {4B1A213C-36A7-41A7-BFC7-B3CFF5795912} + {11F60901-1E1C-4B1B-83E8-261269D2681B} = {D3B68FF7-293B-4458-B8D8-49D3DF59B495} + {BC14CDD1-E127-4DF7-A1B3-55164CA8D1A4} = {D3B68FF7-293B-4458-B8D8-49D3DF59B495} EndGlobalSection EndGlobal diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 42c0f190..0392cffc 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -835,6 +835,8 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True @@ -854,6 +856,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -872,6 +875,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -934,6 +938,7 @@ public void When$condition$_Then$outcome$() True True True + True True True True @@ -993,7 +998,9 @@ public void When$condition$_Then$outcome$() True True True + True True + True True True True diff --git a/src/UnitTesting.Common/OptionalAssertions.cs b/src/UnitTesting.Common/OptionalAssertions.cs index d5a9ca89..bbb1042d 100644 --- a/src/UnitTesting.Common/OptionalAssertions.cs +++ b/src/UnitTesting.Common/OptionalAssertions.cs @@ -26,6 +26,20 @@ public AndConstraint> BeNone(string because = "", par return new AndConstraint>(this); } + public AndConstraint> BeSome(string because = "", + params object[] becauseArgs) + { + Execute.Assertion + .BecauseOf(because, becauseArgs) + .Given(() => Subject) + .ForCondition(optional => optional.HasValue) + .FailWith( + "Expected {context:optional} to have a value {0}{reason}, but it was None instead.", + optional => optional.ValueOrDefault); + + return new AndConstraint>(this); + } + public AndConstraint> BeSome(TValue some, string because = "", params object[] becauseArgs) { @@ -54,4 +68,10 @@ public AndConstraint> BeSome(Predicate some, return new AndConstraint>(this); } + + public AndConstraint> NotBeNone(string because = "", + params object[] becauseArgs) + { + return BeSome(because, becauseArgs); + } } \ No newline at end of file diff --git a/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs b/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs index cec4e118..762c83ed 100644 --- a/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs +++ b/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs @@ -1,5 +1,6 @@ using Application.Resources.Shared; using Common; +using Common.Extensions; using Infrastructure.Interfaces; using Infrastructure.Web.Api.Common.Extensions; using Infrastructure.Web.Api.Interfaces; @@ -12,11 +13,14 @@ public class AuthenticationApi : IWebApiService { private readonly IAuthenticationApplication _authenticationApplication; private readonly ICallerContextFactory _contextFactory; + private readonly IHttpContextAccessor _httpContextAccessor; - public AuthenticationApi(ICallerContextFactory contextFactory, IAuthenticationApplication authenticationApplication) + public AuthenticationApi(ICallerContextFactory contextFactory, IAuthenticationApplication authenticationApplication, + IHttpContextAccessor httpContextAccessor) { _contextFactory = contextFactory; _authenticationApplication = authenticationApplication; + _httpContextAccessor = httpContextAccessor; } public async Task> Authenticate( @@ -24,16 +28,24 @@ public async Task> Authe { var tokens = await _authenticationApplication.AuthenticateAsync(_contextFactory.Create(), request.Provider, request.AuthCode, request.Username, request.Password, cancellationToken); + if (tokens.IsSuccessful) + { + var response = _httpContextAccessor.HttpContext!.Response; + PopulateCookies(response, tokens.Value); + } return () => tokens.HandleApplicationResult(tok => new PostResult(new AuthenticateResponse { UserId = tok.UserId })); } -#pragma warning disable SAS014 public async Task Logout(LogoutRequest request, CancellationToken cancellationToken) -#pragma warning restore SAS014 { var result = await _authenticationApplication.LogoutAsync(_contextFactory.Create(), cancellationToken); + if (result.IsSuccessful) + { + var response = _httpContextAccessor.HttpContext!.Response; + DeleteAuthenticationCookies(response); + } return () => result.Match(() => new Result(), error => new Result(error)); @@ -41,9 +53,58 @@ public async Task Logout(LogoutRequest request, CancellationToke public async Task RefreshToken(RefreshTokenRequest request, CancellationToken cancellationToken) { - var result = await _authenticationApplication.RefreshTokenAsync(_contextFactory.Create(), cancellationToken); + var refreshToken = GetRefreshTokenCookie(_httpContextAccessor.HttpContext!.Request); - return () => result.Match(() => new Result(), + var tokens = + await _authenticationApplication.RefreshTokenAsync(_contextFactory.Create(), refreshToken, + cancellationToken); + if (tokens.IsSuccessful) + { + var response = _httpContextAccessor.HttpContext!.Response; + PopulateCookies(response, tokens.Value); + } + + return () => tokens.Match(_ => new Result(), error => new Result(error)); } + + private static void PopulateCookies(HttpResponse response, AuthenticateTokens tokens) + { + response.Cookies.Append(AuthenticationConstants.Cookies.Token, tokens.AccessToken, + GetCookieOptions(tokens.ExpiresOn)); + response.Cookies.Append(AuthenticationConstants.Cookies.RefreshToken, tokens.RefreshToken, GetCookieOptions()); + } + + private static void DeleteAuthenticationCookies(HttpResponse response) + { + response.Cookies.Delete(AuthenticationConstants.Cookies.Token); + response.Cookies.Delete(AuthenticationConstants.Cookies.RefreshToken); + } + + private static Optional GetRefreshTokenCookie(HttpRequest request) + { + if (request.Cookies.TryGetValue(AuthenticationConstants.Cookies.RefreshToken, out var cookie)) + { + return cookie; + } + + return Optional.None; + } + + private static CookieOptions GetCookieOptions(DateTime? expires = null) + { + var options = new CookieOptions + { + Path = "/", + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Lax + }; + if (expires.HasValue && expires.HasValue()) + { + options.Expires = new DateTimeOffset(expires.Value); + } + + return options; + } } \ No newline at end of file diff --git a/src/WebsiteHost/Application/AuthenticationApplication.cs b/src/WebsiteHost/Application/AuthenticationApplication.cs index 663de9db..28b844c0 100644 --- a/src/WebsiteHost/Application/AuthenticationApplication.cs +++ b/src/WebsiteHost/Application/AuthenticationApplication.cs @@ -12,15 +12,12 @@ namespace WebsiteHost.Application; public class AuthenticationApplication : IAuthenticationApplication { - private readonly IHttpContextAccessor _httpContextAccessor; private readonly IRecorder _recorder; private readonly IServiceClient _serviceClient; - public AuthenticationApplication(IRecorder recorder, IHttpContextAccessor httpContextAccessor, - IServiceClient serviceClient) + public AuthenticationApplication(IRecorder recorder, IServiceClient serviceClient) { _recorder = recorder; - _httpContextAccessor = httpContextAccessor; _serviceClient = serviceClient; } @@ -53,31 +50,24 @@ public async Task> AuthenticateAsync(ICallerCo return authenticated.Error.ToError(); } - var tokens = authenticated.Value.Convert(); - var response = _httpContextAccessor.HttpContext!.Response; - PopulateCookies(response, tokens); _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.UserLogin); - return new Result(tokens); + return authenticated.Value.ToTokens(); } public Task> LogoutAsync(ICallerContext context, CancellationToken cancellationToken) { - var response = _httpContextAccessor.HttpContext!.Response; - DeleteAuthenticationCookies(response); - _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.UserLogout); return Task.FromResult(Result.Ok); } - public async Task> RefreshTokenAsync(ICallerContext context, CancellationToken cancellationToken) + public async Task> RefreshTokenAsync(ICallerContext context, string? refreshToken, + CancellationToken cancellationToken) { - var request = _httpContextAccessor.HttpContext!.Request; - var refreshToken = GetRefreshTokenCookie(request); - if (!refreshToken.HasValue) + if (!refreshToken.HasValue()) { - return Error.EntityNotFound(); + return Error.NotAuthenticated(); } var refreshed = await _serviceClient.PostAsync(context, new RefreshTokenRequest @@ -89,51 +79,27 @@ public async Task> RefreshTokenAsync(ICallerContext context, Cance return refreshed.Error.ToError(); } - var tokens = refreshed.Value.Convert(); - var response = _httpContextAccessor.HttpContext!.Response; - PopulateCookies(response, tokens); _recorder.TrackUsage(context.ToCall(), UsageConstants.Events.UsageScenarios.UserExtendedLogin); - return Result.Ok; - } - - private static void PopulateCookies(HttpResponse response, AuthenticateTokens tokens) - { - response.Cookies.Append(AuthenticationConstants.Cookies.Token, tokens.AccessToken, - GetCookieOptions(tokens.ExpiresOn)); - response.Cookies.Append(AuthenticationConstants.Cookies.RefreshToken, tokens.RefreshToken, GetCookieOptions()); - } - - private static void DeleteAuthenticationCookies(HttpResponse response) - { - response.Cookies.Delete(AuthenticationConstants.Cookies.Token); - response.Cookies.Delete(AuthenticationConstants.Cookies.RefreshToken); + return refreshed.Value.ToTokens(); } +} - private static Optional GetRefreshTokenCookie(HttpRequest request) +internal static class AuthenticationConversionExtensions +{ + public static AuthenticateTokens ToTokens(this AuthenticateResponse response) { - if (request.Cookies.TryGetValue(AuthenticationConstants.Cookies.RefreshToken, out var cookie)) - { - return cookie; - } + var tokens = response.Convert(); + tokens.ExpiresOn = response.ExpiresOnUtc ?? DateTime.UtcNow; - return Optional.None; + return tokens; } - private static CookieOptions GetCookieOptions(DateTime? expires = null) + public static AuthenticateTokens ToTokens(this RefreshTokenResponse response) { - var options = new CookieOptions - { - Path = "/", - HttpOnly = true, - Secure = true, - SameSite = SameSiteMode.Lax - }; - if (expires.HasValue && expires.HasValue()) - { - options.Expires = new DateTimeOffset(expires.Value); - } + var tokens = response.Convert(); + tokens.ExpiresOn = response.ExpiresOnUtc ?? DateTime.UtcNow; - return options; + return tokens; } } \ No newline at end of file diff --git a/src/WebsiteHost/Application/IAuthenticationApplication.cs b/src/WebsiteHost/Application/IAuthenticationApplication.cs index c5d6bd95..7239edb4 100644 --- a/src/WebsiteHost/Application/IAuthenticationApplication.cs +++ b/src/WebsiteHost/Application/IAuthenticationApplication.cs @@ -11,5 +11,6 @@ Task> AuthenticateAsync(ICallerContext context Task> LogoutAsync(ICallerContext context, CancellationToken cancellationToken); - Task> RefreshTokenAsync(ICallerContext context, CancellationToken cancellationToken); + Task> RefreshTokenAsync(ICallerContext context, string? refreshToken, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/WebsiteHost/BackEndForFrontEndModule.cs b/src/WebsiteHost/BackEndForFrontEndModule.cs index 4cb2ac89..038e1488 100644 --- a/src/WebsiteHost/BackEndForFrontEndModule.cs +++ b/src/WebsiteHost/BackEndForFrontEndModule.cs @@ -1,9 +1,12 @@ using System.Reflection; using System.Text.Json; using Application.Interfaces.Services; +using Domain.Services.Shared.DomainServices; +using Infrastructure.Common.DomainServices; using Infrastructure.Hosting.Common.Extensions; using Infrastructure.Web.Common.Clients; using Infrastructure.Web.Hosting.Common; +using Infrastructure.Web.Hosting.Common.Pipeline; using Infrastructure.Web.Interfaces.Clients; using WebsiteHost.Api.Recording; using WebsiteHost.Application; @@ -20,7 +23,25 @@ public class BackEndForFrontEndModule : ISubDomainModule public Action> ConfigureMiddleware { - get { return (app, _) => app.RegisterRoutes(); } + get + { + return (app, middlewares) => + { + app.RegisterRoutes(); + middlewares.Add(new MiddlewareRegistration(33, webApp => + { + if (!webApp.Environment.IsDevelopment()) + { + webApp.UseExceptionHandler("/Home/Error"); + } + + webApp.UseRouting(); + webApp.MapControllerRoute( + "default", + "{controller=Home}/{action=Index}/{id?}"); + }, "Pipeline: MVC controllers are enabled")); + }; + } } public Action RegisterServices @@ -29,12 +50,16 @@ public Action RegisterServices { return (_, services) => { + services.AddControllers(); services.RegisterUnshared(); services.RegisterUnshared(); services.RegisterUnshared(c => new InterHostServiceClient(c.Resolve(), c.Resolve(), c.Resolve().GetApiHost1BaseUrl())); + services.RegisterUnshared(c => new AesEncryptionService(c + .ResolveForUnshared().GetWebsiteHostCSRFEncryptionSecret())); + services.RegisterUnshared(); }; } } diff --git a/src/WebsiteHost/Controllers/CSRFController.cs b/src/WebsiteHost/Controllers/CSRFController.cs new file mode 100644 index 00000000..cfa803c7 --- /dev/null +++ b/src/WebsiteHost/Controllers/CSRFController.cs @@ -0,0 +1,50 @@ +using System.Text; +using Common; +using Infrastructure.Web.Api.Common; +using Infrastructure.Web.Hosting.Common.Extensions; +using Infrastructure.Web.Hosting.Common.Pipeline; +using Microsoft.AspNetCore.Mvc; + +namespace WebsiteHost.Controllers; + +public abstract class CSRFController : Controller +{ + private readonly CSRFMiddleware.ICSRFService _csrfService; + + protected CSRFController(CSRFMiddleware.ICSRFService csrfCSRFService) + { + _csrfService = csrfCSRFService; + } + + protected IActionResult CSRFResult(string pageHtml) + { + var userId = Request.GetUserIdFromAuthNCookie() + .Match(optional => optional, _ => Optional>.None); + var csrfTokenPair = _csrfService.CreateTokens(userId); + WriteSignatureToCookie(csrfTokenPair.Signature); + var contents = WriteTokenToHtmlMetadata(pageHtml, csrfTokenPair.Token); + + return File(contents, HttpContentTypes.Html); + } + + private void WriteSignatureToCookie(string signature) + { + Response.Cookies.Append(CSRFConstants.Cookies.AntiCSRF, signature, new CookieOptions + { + Secure = true, + HttpOnly = true, + Expires = DateTime.UtcNow.Add(CSRFConstants.Cookies.DefaultCSRFExpiry), + SameSite = SameSiteMode.Lax + }); + } + + private static byte[] WriteTokenToHtmlMetadata(string pageHtml, string token) + { + var html = pageHtml; + html = html + .Replace(CSRFConstants.Html.CSRFFieldNamePlaceholder, CSRFConstants.Html.CSRFRequestFieldName) + .Replace(CSRFConstants.Html.CSRFTokenPlaceholder, token); + + return Encoding.UTF8.GetBytes(html); + } +} \ No newline at end of file diff --git a/src/WebsiteHost/Controllers/Home/HomeController.cs b/src/WebsiteHost/Controllers/Home/HomeController.cs new file mode 100644 index 00000000..2b168c06 --- /dev/null +++ b/src/WebsiteHost/Controllers/Home/HomeController.cs @@ -0,0 +1,90 @@ +using System.Text; +using Common.Extensions; +using Infrastructure.Web.Hosting.Common.Pipeline; +using Microsoft.AspNetCore.Mvc; + +namespace WebsiteHost.Controllers.Home; + +public class HomeController : CSRFController +{ + private const string IndexHtmlFileName = "index.html"; + private static string? _cachedIndexHtml; + private readonly IWebHostEnvironment _hostEnvironment; + + public HomeController(IWebHostEnvironment hostEnvironment, CSRFMiddleware.ICSRFService csrfService) : + base(csrfService) + { + _hostEnvironment = hostEnvironment; + EnsureWebsiteIsBuilt(); + } + + [Route("/error")] + public IActionResult Error() + { + return Problem(); + } + + public IActionResult Index() + { + var pageHtml = GetCachedHtml(); + pageHtml = WriteCompilationOptionsToHtml(pageHtml); + + return CSRFResult(pageHtml); + } + + private void EnsureWebsiteIsBuilt() + { + var indexHtmlPath = GetIndexHtmlPath(); + if (!System.IO.File.Exists(indexHtmlPath)) + { + throw new InvalidOperationException(Resources.HomeController_IndexPageNotBuilt.Format(IndexHtmlFileName)); + } + } + + private string GetCachedHtml() + { + if (_cachedIndexHtml.NotExists()) + { + var indexHtmlPath = GetIndexHtmlPath(); + _cachedIndexHtml = + System.IO.File.ReadAllText(indexHtmlPath); + } + + return _cachedIndexHtml; + } + + private string GetIndexHtmlPath() + { + var rootPath = _hostEnvironment.WebRootPath; + return Path.GetFullPath(Path.Combine(rootPath, IndexHtmlFileName)); + } + + private static string WriteCompilationOptionsToHtml(string pageHtml) + { + const string endHeadTag = ""; + var html = pageHtml; + + var endHeadTagIndex = html.IndexOf(endHeadTag, StringComparison.OrdinalIgnoreCase); + if (endHeadTagIndex > -1) + { + var scriptToAdd = new StringBuilder(""); + + var javascript = scriptToAdd.ToString(); + html = html.Insert(endHeadTagIndex + endHeadTag.Length, javascript); + } + + return html; + } +} \ No newline at end of file diff --git a/src/WebsiteHost/Resources.Designer.cs b/src/WebsiteHost/Resources.Designer.cs index 807375fd..e0a5ec3f 100644 --- a/src/WebsiteHost/Resources.Designer.cs +++ b/src/WebsiteHost/Resources.Designer.cs @@ -104,6 +104,15 @@ internal static string AuthenticateRequestValidator_InvalidUsername { } } + /// + /// Looks up a localized string similar to The file '{0}' cannot be found in the directory {rootPath}. Please make sure you have pre-built the JS application by running `npm run build`. + /// + internal static string HomeController_IndexPageNotBuilt { + get { + return ResourceManager.GetString("HomeController_IndexPageNotBuilt", resourceCulture); + } + } + /// /// Looks up a localized string similar to The 'Message' was either missing or invalid. /// diff --git a/src/WebsiteHost/Resources.resx b/src/WebsiteHost/Resources.resx index ecac9e6b..12b52196 100644 --- a/src/WebsiteHost/Resources.resx +++ b/src/WebsiteHost/Resources.resx @@ -57,4 +57,7 @@ The 'Password' is either missing or invalid + + The file '{0}' cannot be found in the directory {rootPath}. Please make sure you have pre-built the JS application by running `npm run build` + \ No newline at end of file diff --git a/src/WebsiteHost/appsettings.json b/src/WebsiteHost/appsettings.json index c7591d65..068d5452 100644 --- a/src/WebsiteHost/appsettings.json +++ b/src/WebsiteHost/appsettings.json @@ -21,10 +21,12 @@ }, "AncillaryApi": { "BaseUrl": "https://localhost:5001", - "HmacAuthNSecret": "asecret" + "HMACAuthNSecret": "asecret" }, "WebsiteHost": { - "BaseUrl": "https://localhost:5101" + "BaseUrl": "https://localhost:5101", + "CSRFHMACSecret": "asecret", + "CSRFAESSecret": "VwnDwu0VqnKP2ckUezA/mZIrTMyUIGLU4QoqFLpb92k=::XobUBsNUiBJ9GTHHNvh8Ug==" }, "AllowedCORSOrigins": "https://localhost:5101" } diff --git a/src/WebsiteHost/wwwroot/index.html b/src/WebsiteHost/wwwroot/index.html index bcc96a0c..86c2a963 100644 --- a/src/WebsiteHost/wwwroot/index.html +++ b/src/WebsiteHost/wwwroot/index.html @@ -2,9 +2,12 @@ - Home + + + SaaStack -

SaaStack Home

+ +

SaaStack Home

\ No newline at end of file