From 3848b74fd272d237bd5aba62847ffec19197eba7 Mon Sep 17 00:00:00 2001
From: RobertGHippo
Date: Wed, 5 Jun 2024 12:25:00 +0100
Subject: [PATCH 01/28] All controllers redirect to challenge url.
---
.../AccessibilityStatementController.cs | 3 ++-
.../Controllers/AdviceController.cs | 5 ++--
.../Controllers/Base/ServiceController.cs | 9 ++++++++
.../Controllers/ChallengeController.cs | 16 +++++++++++++
.../Controllers/CookiesController.cs | 12 ++++++----
.../Controllers/HealthController.cs | 3 ++-
.../Controllers/HomeController.cs | 3 ++-
.../QualificationDetailsController.cs | 3 ++-
.../Controllers/QuestionsController.cs | 3 ++-
.../ChallengeResourceFilterAttribute.cs | 23 +++++++++++++++++++
10 files changed, 68 insertions(+), 12 deletions(-)
create mode 100644 src/Dfe.EarlyYearsQualification.Web/Controllers/Base/ServiceController.cs
create mode 100644 src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs
create mode 100644 src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/AccessibilityStatementController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/AccessibilityStatementController.cs
index e27a62ef..b6d0d9f3 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Controllers/AccessibilityStatementController.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/AccessibilityStatementController.cs
@@ -1,6 +1,7 @@
using Dfe.EarlyYearsQualification.Content.Entities;
using Dfe.EarlyYearsQualification.Content.Renderers.Entities;
using Dfe.EarlyYearsQualification.Content.Services;
+using Dfe.EarlyYearsQualification.Web.Controllers.Base;
using Dfe.EarlyYearsQualification.Web.Models.Content;
using Microsoft.AspNetCore.Mvc;
@@ -11,7 +12,7 @@ public class AccessibilityStatementController(
ILogger logger,
IContentService contentService,
IHtmlRenderer renderer)
- : Controller
+ : ServiceController
{
[HttpGet]
public async Task Index()
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/AdviceController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/AdviceController.cs
index c58ad539..559e4c8f 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Controllers/AdviceController.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/AdviceController.cs
@@ -2,6 +2,7 @@
using Dfe.EarlyYearsQualification.Content.Entities;
using Dfe.EarlyYearsQualification.Content.Renderers.Entities;
using Dfe.EarlyYearsQualification.Content.Services;
+using Dfe.EarlyYearsQualification.Web.Controllers.Base;
using Dfe.EarlyYearsQualification.Web.Models.Content;
using Microsoft.AspNetCore.Mvc;
@@ -9,7 +10,7 @@ namespace Dfe.EarlyYearsQualification.Web.Controllers;
[Route("/advice")]
public class AdviceController(ILogger logger, IContentService contentService, IHtmlRenderer renderer)
- : Controller
+ : ServiceController
{
[HttpGet("qualification-outside-the-united-kingdom")]
public async Task QualificationOutsideTheUnitedKingdom()
@@ -22,7 +23,7 @@ private async Task GetView(string advicePageId)
var advicePage = await contentService.GetAdvicePage(advicePageId);
if (advicePage is null)
{
- logger.LogError("No content for the advice page");
+ logger.LogError("No content for the advice page");
return RedirectToAction("Error", "Home");
}
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/Base/ServiceController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/Base/ServiceController.cs
new file mode 100644
index 00000000..bbe81a10
--- /dev/null
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/Base/ServiceController.cs
@@ -0,0 +1,9 @@
+using Dfe.EarlyYearsQualification.Web.Filters;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Dfe.EarlyYearsQualification.Web.Controllers.Base;
+
+[ChallengeResourceFilter]
+public class ServiceController : Controller
+{
+}
\ No newline at end of file
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs
new file mode 100644
index 00000000..20bfaf06
--- /dev/null
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs
@@ -0,0 +1,16 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace Dfe.EarlyYearsQualification.Web.Controllers;
+
+[Route("/challenge")]
+public class ChallengeController(
+ ILogger logger)
+ : Controller
+{
+ [HttpGet]
+ public Task Index()
+ {
+ logger.LogWarning("Challenge page invoked");
+ return Task.FromResult(new OkResult());
+ }
+}
\ No newline at end of file
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/CookiesController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/CookiesController.cs
index 7e930317..3d7c6975 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Controllers/CookiesController.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/CookiesController.cs
@@ -1,6 +1,7 @@
using Dfe.EarlyYearsQualification.Content.Entities;
using Dfe.EarlyYearsQualification.Content.Renderers.Entities;
using Dfe.EarlyYearsQualification.Content.Services;
+using Dfe.EarlyYearsQualification.Web.Controllers.Base;
using Dfe.EarlyYearsQualification.Web.Models.Content;
using Dfe.EarlyYearsQualification.Web.Services.CookieService;
using Microsoft.AspNetCore.Mvc;
@@ -15,7 +16,7 @@ public class CookiesController(
ISuccessBannerRenderer successBannerRenderer,
ICookieService cookieService,
IUrlHelper urlHelper)
- : Controller
+ : ServiceController
{
[HttpGet]
public async Task Index()
@@ -34,21 +35,21 @@ public async Task Index()
}
[HttpPost("accept")]
- public IActionResult Accept([FromForm]string? returnUrl)
+ public IActionResult Accept([FromForm] string? returnUrl)
{
cookieService.SetPreference(true);
return Redirect(CheckUrl(returnUrl));
}
[HttpPost("reject")]
- public IActionResult Reject([FromForm]string? returnUrl)
+ public IActionResult Reject([FromForm] string? returnUrl)
{
cookieService.RejectCookies();
return Redirect(CheckUrl(returnUrl));
}
[HttpPost("hidebanner")]
- public IActionResult HideBanner([FromForm]string? returnUrl)
+ public IActionResult HideBanner([FromForm] string? returnUrl)
{
cookieService.SetVisibility(false);
return Redirect(CheckUrl(returnUrl));
@@ -65,13 +66,14 @@ public IActionResult CookiePreference(string cookiesAnswer)
{
cookieService.RejectCookies();
}
+
TempData["UserPreferenceRecorded"] = true;
return Redirect("/cookies");
}
private string CheckUrl(string? url)
{
- return urlHelper.IsLocalUrl(url) ? url : "/cookies";
+ return urlHelper.IsLocalUrl(url) ? url : "/cookies";
}
private async Task Map(CookiesPage content)
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/HealthController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/HealthController.cs
index c5b9c5af..b1311e87 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Controllers/HealthController.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/HealthController.cs
@@ -1,10 +1,11 @@
+using Dfe.EarlyYearsQualification.Web.Controllers.Base;
using Microsoft.AspNetCore.Mvc;
namespace Dfe.EarlyYearsQualification.Web.Controllers;
[ApiController]
[Route("[controller]")]
-public class HealthController : Controller
+public class HealthController : ServiceController
{
[HttpGet]
public IActionResult Get()
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/HomeController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/HomeController.cs
index 00365434..5d9c30a0 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Controllers/HomeController.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/HomeController.cs
@@ -2,6 +2,7 @@
using Dfe.EarlyYearsQualification.Content.Entities;
using Dfe.EarlyYearsQualification.Content.Renderers.Entities;
using Dfe.EarlyYearsQualification.Content.Services;
+using Dfe.EarlyYearsQualification.Web.Controllers.Base;
using Dfe.EarlyYearsQualification.Web.Models;
using Dfe.EarlyYearsQualification.Web.Models.Content;
using Microsoft.AspNetCore.Mvc;
@@ -13,7 +14,7 @@ public class HomeController(
IContentService contentService,
IHtmlRenderer htmlRenderer,
ISideContentRenderer sideContentRenderer)
- : Controller
+ : ServiceController
{
[HttpGet]
public async Task Index()
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/QualificationDetailsController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/QualificationDetailsController.cs
index 88358db8..1fcba4b8 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Controllers/QualificationDetailsController.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/QualificationDetailsController.cs
@@ -1,6 +1,7 @@
using Dfe.EarlyYearsQualification.Content.Entities;
using Dfe.EarlyYearsQualification.Content.Renderers.Entities;
using Dfe.EarlyYearsQualification.Content.Services;
+using Dfe.EarlyYearsQualification.Web.Controllers.Base;
using Dfe.EarlyYearsQualification.Web.Models.Content;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
@@ -12,7 +13,7 @@ public class QualificationDetailsController(
ILogger logger,
IContentService contentService,
IGovUkInsetTextRenderer renderer)
- : Controller
+ : ServiceController
{
[HttpGet]
public IActionResult Get()
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/QuestionsController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/QuestionsController.cs
index abd04c8d..0b33c575 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Controllers/QuestionsController.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/QuestionsController.cs
@@ -3,6 +3,7 @@
using Dfe.EarlyYearsQualification.Content.Renderers.Entities;
using Dfe.EarlyYearsQualification.Content.Services;
using Dfe.EarlyYearsQualification.Web.Constants;
+using Dfe.EarlyYearsQualification.Web.Controllers.Base;
using Dfe.EarlyYearsQualification.Web.Models.Content;
using Microsoft.AspNetCore.Mvc;
@@ -13,7 +14,7 @@ public class QuestionsController(
ILogger logger,
IContentService contentService,
IHtmlRenderer renderer)
- : Controller
+ : ServiceController
{
private const string Questions = "Questions";
diff --git a/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs b/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
new file mode 100644
index 00000000..fd3ba6fb
--- /dev/null
+++ b/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
@@ -0,0 +1,23 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace Dfe.EarlyYearsQualification.Web.Filters;
+
+public class ChallengeResourceFilterAttribute : Attribute, IResourceFilter
+{
+ private const string ChallengeSecret = "CX";
+
+ public void OnResourceExecuting(ResourceExecutingContext context)
+ {
+ if (!context.HttpContext.Request.Cookies.ContainsKey("auth-secret")
+ || !context.HttpContext.Request.Cookies["auth-secret"]!.Equals(ChallengeSecret))
+ {
+ context.Result = new RedirectResult("/challenge");
+ }
+ }
+
+ public void OnResourceExecuted(ResourceExecutedContext context)
+ {
+ // do nothing
+ }
+}
\ No newline at end of file
From 0f28df957619f7261ae997fc3fdb5bba8fbd3b64 Mon Sep 17 00:00:00 2001
From: RobertGHippo
Date: Thu, 6 Jun 2024 11:55:47 +0100
Subject: [PATCH 02/28] Simple cookie-based solution working with hard-coded
secret.
---
.../Controllers/Base/ServiceController.cs | 2 +-
.../Controllers/ChallengeController.cs | 34 ++++++++++--
.../ChallengeResourceFilterAttribute.cs | 33 ++++++++++--
.../Program.cs | 16 +++---
.../Controllers/ChallengeControllerTests.cs | 52 +++++++++++++++++++
5 files changed, 122 insertions(+), 15 deletions(-)
create mode 100644 tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/Base/ServiceController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/Base/ServiceController.cs
index bbe81a10..5b90b8ec 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Controllers/Base/ServiceController.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/Base/ServiceController.cs
@@ -3,7 +3,7 @@
namespace Dfe.EarlyYearsQualification.Web.Controllers.Base;
-[ChallengeResourceFilter]
+[ServiceFilter]
public class ServiceController : Controller
{
}
\ No newline at end of file
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs
index 20bfaf06..77317b81 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs
@@ -1,16 +1,44 @@
+using Dfe.EarlyYearsQualification.Web.Filters;
using Microsoft.AspNetCore.Mvc;
namespace Dfe.EarlyYearsQualification.Web.Controllers;
-[Route("/challenge")]
public class ChallengeController(
ILogger logger)
: Controller
{
+ [Route("/challenge")]
[HttpGet]
- public Task Index()
+ public Task Index([FromQuery(Name = "from")] string? from,
+ [FromQuery(Name = "access-value")] string? accessValue)
{
+ from ??= "/";
+
+ if (accessValue != null)
+ {
+ logger.LogInformation("Challenge secret access value entered successfully");
+
+ HttpContext.Response.Cookies.Append(ChallengeResourceFilterAttribute.AuthSecretCookieName, accessValue);
+ return Task.FromResult(new RedirectResult(from));
+ }
+
logger.LogWarning("Challenge page invoked");
- return Task.FromResult(new OkResult());
+
+ return Task.FromResult(Content($"""
+
+
+
+ If you have been invited to view this preview service, you will have been
+ given a value that will grant access. Please type in the value and click 'Submit'.
+
+
+
+
+ """,
+ "text/html"));
}
}
\ No newline at end of file
diff --git a/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs b/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
index fd3ba6fb..d89147a9 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
@@ -1,19 +1,42 @@
+using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Dfe.EarlyYearsQualification.Web.Filters;
-public class ChallengeResourceFilterAttribute : Attribute, IResourceFilter
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
+public class ChallengeResourceFilterAttribute(ILogger logger)
+ : Attribute, IResourceFilter
{
- private const string ChallengeSecret = "CX";
+ public const string AuthSecretCookieName = "auth-secret";
+ public const string Challenge = "CX";
+
+ private const bool RedirectIsPermanent = false;
+ private const bool RedirectPreservesMethod = true;
public void OnResourceExecuting(ResourceExecutingContext context)
{
- if (!context.HttpContext.Request.Cookies.ContainsKey("auth-secret")
- || !context.HttpContext.Request.Cookies["auth-secret"]!.Equals(ChallengeSecret))
+ if (context.HttpContext.Request.Cookies.ContainsKey(AuthSecretCookieName)
+ && context.HttpContext.Request.Cookies[AuthSecretCookieName]!.Equals(Challenge))
{
- context.Result = new RedirectResult("/challenge");
+ return;
}
+
+ logger.LogWarning($"Access denied by {nameof(ChallengeResourceFilterAttribute)}");
+
+ var requestedUri = context.HttpContext.Request.GetEncodedUrl();
+
+ var uriBuilder = new UriBuilder(requestedUri)
+ {
+ Path = "/challenge",
+ Query = $"from={requestedUri}"
+ };
+
+ var redirectUri = uriBuilder.Uri;
+
+ context.Result = new RedirectResult(redirectUri.ToString(),
+ RedirectIsPermanent,
+ RedirectPreservesMethod);
}
public void OnResourceExecuted(ResourceExecutedContext context)
diff --git a/src/Dfe.EarlyYearsQualification.Web/Program.cs b/src/Dfe.EarlyYearsQualification.Web/Program.cs
index a7e0a8af..6f76db45 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Program.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Program.cs
@@ -4,6 +4,7 @@
using Dfe.EarlyYearsQualification.Content.Extensions;
using Dfe.EarlyYearsQualification.Content.Services;
using Dfe.EarlyYearsQualification.Mock.Extensions;
+using Dfe.EarlyYearsQualification.Web.Filters;
using Dfe.EarlyYearsQualification.Web.Security;
using Dfe.EarlyYearsQualification.Web.Services.CookieService;
using Microsoft.AspNetCore.DataProtection;
@@ -51,11 +52,14 @@
builder.Services.AddTransient();
builder.Services.AddSingleton();
-builder.Services.AddScoped(x => {
- var actionContext = x.GetRequiredService().ActionContext;
- var factory = x.GetRequiredService();
- return factory.GetUrlHelper(actionContext!);
-});
+builder.Services.AddScoped(x =>
+ {
+ var actionContext = x.GetRequiredService().ActionContext;
+ var factory = x.GetRequiredService();
+ return factory.GetUrlHelper(actionContext!);
+ });
+
+builder.Services.AddScoped();
builder.Services.AddStaticRobotsTxt(robotsTxtOptions => robotsTxtOptions.DenyAll());
@@ -84,7 +88,7 @@
"default",
"{controller=Home}/{action=Index}/{id?}");
-app.Run();
+await app.RunAsync();
[ExcludeFromCodeCoverage]
diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs
new file mode 100644
index 00000000..b661f59f
--- /dev/null
+++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs
@@ -0,0 +1,52 @@
+using Dfe.EarlyYearsQualification.Web.Controllers;
+using Dfe.EarlyYearsQualification.Web.Filters;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace Dfe.EarlyYearsQualification.UnitTests.Controllers;
+
+[TestClass]
+public class ChallengeControllerTests
+{
+ [TestMethod]
+ public async Task GetChallenge_Returns_Page()
+ {
+ var controller = new ChallengeController(NullLogger.Instance);
+
+ var result = await controller.Index("/", null);
+
+ result.Should().BeAssignableTo();
+ }
+
+ [TestMethod]
+ public async Task GetChallenge_WithCorrectValue_RedirectsWithCookie()
+ {
+ var cookies = new Dictionary();
+
+ var cookiesMock = new Mock();
+ cookiesMock.Setup(c =>
+ c.Append(ChallengeResourceFilterAttribute.AuthSecretCookieName,
+ ChallengeResourceFilterAttribute.Challenge))
+ .Callback((string k, string v) => cookies.Add(k, v));
+
+ var mockContext = new Mock();
+ mockContext.SetupGet(c => c.Response.Cookies).Returns(cookiesMock.Object);
+
+ var controller = new ChallengeController(NullLogger.Instance);
+ controller.ControllerContext = new ControllerContext
+ {
+ HttpContext = mockContext.Object
+ };
+
+ var result = await controller.Index("/url", ChallengeResourceFilterAttribute.Challenge);
+
+ result.Should().BeAssignableTo();
+
+ cookies.Should().ContainKey(ChallengeResourceFilterAttribute.AuthSecretCookieName);
+ cookies[ChallengeResourceFilterAttribute.AuthSecretCookieName].Should()
+ .Be(ChallengeResourceFilterAttribute.Challenge);
+ }
+}
\ No newline at end of file
From 8e35a35e58ff5bd8eb770c34a91c1b64ee287fbc Mon Sep 17 00:00:00 2001
From: RobertGHippo
Date: Fri, 7 Jun 2024 18:32:21 +0100
Subject: [PATCH 03/28] Postback not quite working yet
---
.../Controllers/ChallengeController.cs | 74 ++++---
.../ChallengeResourceFilterAttribute.cs | 38 ++--
.../Models/ChallengeModel.cs | 8 +
.../Views/Challenge/EntryForm.cshtml | 24 ++
.../Controllers/ChallengeControllerTests.cs | 116 +++++++++-
.../ChallengeResourceFilterAttributeTests.cs | 208 ++++++++++++++++++
6 files changed, 422 insertions(+), 46 deletions(-)
create mode 100644 src/Dfe.EarlyYearsQualification.Web/Models/ChallengeModel.cs
create mode 100644 src/Dfe.EarlyYearsQualification.Web/Views/Challenge/EntryForm.cshtml
create mode 100644 tests/Dfe.EarlyYearsQualification.UnitTests/Filters/ChallengeResourceFilterAttributeTests.cs
diff --git a/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs b/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs
index 77317b81..51fc8a6a 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Controllers/ChallengeController.cs
@@ -1,44 +1,68 @@
using Dfe.EarlyYearsQualification.Web.Filters;
+using Dfe.EarlyYearsQualification.Web.Models;
using Microsoft.AspNetCore.Mvc;
namespace Dfe.EarlyYearsQualification.Web.Controllers;
public class ChallengeController(
- ILogger logger)
+ ILogger logger,
+ IUrlHelper urlHelper)
: Controller
{
- [Route("/challenge")]
+ private const string DefaultRedirectAddress = "/";
+
[HttpGet]
- public Task Index([FromQuery(Name = "from")] string? from,
- [FromQuery(Name = "access-value")] string? accessValue)
+ public Task Index([FromQuery] ChallengeModel model)
{
- from ??= "/";
+ if (!ModelState.IsValid)
+ {
+ logger.LogWarning("Invalid challenge model (get)");
+ }
+
+ model.RedirectAddress = SanitiseReferralAddress(model.RedirectAddress);
+
+ logger.LogWarning("Challenge page invoked");
- if (accessValue != null)
+ return Task.FromResult(View("EntryForm", model));
+ }
+
+ [HttpPost]
+ public Task Post([FromForm] ChallengeModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ logger.LogWarning("Invalid challenge model (post)");
+ }
+
+ var referralAddress = SanitiseReferralAddress(model.RedirectAddress);
+
+ if (model.Value != null)
{
logger.LogInformation("Challenge secret access value entered successfully");
- HttpContext.Response.Cookies.Append(ChallengeResourceFilterAttribute.AuthSecretCookieName, accessValue);
- return Task.FromResult(new RedirectResult(from));
+ SetAuthSecretCookie(model.Value);
+ return Task.FromResult(new RedirectResult(referralAddress));
}
- logger.LogWarning("Challenge page invoked");
+ return Index(model);
+ }
+
+ private void SetAuthSecretCookie(string accessValue)
+ {
+ HttpContext.Response
+ .Cookies
+ .Append(ChallengeResourceFilterAttribute.AuthSecretCookieName, accessValue);
+ }
+
+ private string SanitiseReferralAddress(string? from)
+ {
+ var redirectAddress = from ?? DefaultRedirectAddress;
+
+ if (!urlHelper.IsLocalUrl(redirectAddress))
+ {
+ redirectAddress = DefaultRedirectAddress;
+ }
- return Task.FromResult(Content($"""
-
-
-
- If you have been invited to view this preview service, you will have been
- given a value that will grant access. Please type in the value and click 'Submit'.
-
-
-
-
- """,
- "text/html"));
+ return redirectAddress;
}
}
\ No newline at end of file
diff --git a/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs b/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
index d89147a9..7c1e4a8a 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
@@ -9,34 +9,42 @@ public class ChallengeResourceFilterAttribute(ILogger
+
+
+
+ Challenge
+
+
+
+ If you have been invited to view this preview service, you will have been
+ given a value that will grant access. Please type in the value and click 'Submit'.
+
+
+
+
\ No newline at end of file
diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs
index b661f59f..825867aa 100644
--- a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs
+++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs
@@ -1,5 +1,6 @@
using Dfe.EarlyYearsQualification.Web.Controllers;
using Dfe.EarlyYearsQualification.Web.Filters;
+using Dfe.EarlyYearsQualification.Web.Models;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -14,16 +15,111 @@ public class ChallengeControllerTests
[TestMethod]
public async Task GetChallenge_Returns_Page()
{
- var controller = new ChallengeController(NullLogger.Instance);
+ const string from = "/";
- var result = await controller.Index("/", null);
+ var mockUrlHelper = new Mock();
+ mockUrlHelper.Setup(u => u.IsLocalUrl(It.IsAny()))
+ .Returns(true);
- result.Should().BeAssignableTo();
+ var controller = new ChallengeController(NullLogger.Instance,
+ mockUrlHelper.Object);
+
+ controller.ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext()
+ };
+
+ var result = await controller.Index(new ChallengeModel { RedirectAddress = "/", Value = null });
+
+ result.Should().BeAssignableTo();
+
+ var content = (ViewResult)result;
+
+ content.Model.Should().BeAssignableTo()
+ .Which
+ .RedirectAddress.Should().Be(from);
+ }
+
+ [TestMethod]
+ public async Task GetChallenge_NonLocalFrom_Returns_Page_With_BaseFrom()
+ {
+ const string from = "https://google.co.uk";
+
+ var mockUrlHelper = new Mock();
+ mockUrlHelper.Setup(u => u.IsLocalUrl(It.IsAny()))
+ .Returns(false);
+
+ var controller = new ChallengeController(NullLogger.Instance,
+ mockUrlHelper.Object);
+
+ controller.ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext()
+ };
+
+ var result = await controller.Index(new ChallengeModel { RedirectAddress = from, Value = null });
+
+ result.Should().BeAssignableTo();
+
+ var content = (ViewResult)result;
+
+ content.Model.Should().BeAssignableTo()
+ .Which
+ .RedirectAddress.Should().Be(from);
+ }
+
+ [TestMethod]
+ public async Task PostChallenge_WithCorrectValue_RedirectsWithCookie()
+ {
+ const string from = "/cookies";
+
+ var mockUrlHelper = new Mock();
+ mockUrlHelper.Setup(u => u.IsLocalUrl(It.IsAny()))
+ .Returns(true);
+
+ var cookies = new Dictionary();
+
+ var cookiesMock = new Mock();
+ cookiesMock.Setup(c =>
+ c.Append(ChallengeResourceFilterAttribute.AuthSecretCookieName,
+ ChallengeResourceFilterAttribute.Challenge))
+ .Callback((string k, string v) => cookies.Add(k, v));
+
+ var mockContext = new Mock();
+ mockContext.SetupGet(c => c.Response.Cookies).Returns(cookiesMock.Object);
+
+ var controller = new ChallengeController(NullLogger.Instance,
+ mockUrlHelper.Object);
+ controller.ControllerContext = new ControllerContext
+ {
+ HttpContext = mockContext.Object
+ };
+
+ var result = await controller.Post(new ChallengeModel
+ {
+ RedirectAddress = from,
+ Value = ChallengeResourceFilterAttribute.Challenge
+ });
+
+ result.Should().BeAssignableTo();
+
+ var redirect = (RedirectResult)result;
+ redirect.Url.Should().Be("/cookies");
+
+ cookies.Should().ContainKey(ChallengeResourceFilterAttribute.AuthSecretCookieName);
+ cookies[ChallengeResourceFilterAttribute.AuthSecretCookieName].Should()
+ .Be(ChallengeResourceFilterAttribute.Challenge);
}
[TestMethod]
- public async Task GetChallenge_WithCorrectValue_RedirectsWithCookie()
+ public async Task PostChallenge_WithCorrectValue_ButNonLocalFrom_RedirectsWithCookie_ToBaseUrl()
{
+ const string from = "https://google.co.uk";
+
+ var mockUrlHelper = new Mock();
+ mockUrlHelper.Setup(u => u.IsLocalUrl(It.IsAny()))
+ .Returns(false); // NB: behaviour relies on UrlHelper correctly determining non-local URLs
+
var cookies = new Dictionary();
var cookiesMock = new Mock();
@@ -35,16 +131,24 @@ public async Task GetChallenge_WithCorrectValue_RedirectsWithCookie()
var mockContext = new Mock();
mockContext.SetupGet(c => c.Response.Cookies).Returns(cookiesMock.Object);
- var controller = new ChallengeController(NullLogger.Instance);
+ var controller = new ChallengeController(NullLogger.Instance,
+ mockUrlHelper.Object);
controller.ControllerContext = new ControllerContext
{
HttpContext = mockContext.Object
};
- var result = await controller.Index("/url", ChallengeResourceFilterAttribute.Challenge);
+ var result = await controller.Post(new ChallengeModel
+ {
+ RedirectAddress = from,
+ Value = ChallengeResourceFilterAttribute.Challenge
+ });
result.Should().BeAssignableTo();
+ var redirect = (RedirectResult)result;
+ redirect.Url.Should().Be("/");
+
cookies.Should().ContainKey(ChallengeResourceFilterAttribute.AuthSecretCookieName);
cookies[ChallengeResourceFilterAttribute.AuthSecretCookieName].Should()
.Be(ChallengeResourceFilterAttribute.Challenge);
diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Filters/ChallengeResourceFilterAttributeTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Filters/ChallengeResourceFilterAttributeTests.cs
new file mode 100644
index 00000000..ee2fc707
--- /dev/null
+++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Filters/ChallengeResourceFilterAttributeTests.cs
@@ -0,0 +1,208 @@
+using Dfe.EarlyYearsQualification.UnitTests.Extensions;
+using Dfe.EarlyYearsQualification.Web.Filters;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace Dfe.EarlyYearsQualification.UnitTests.Filters;
+
+[TestClass]
+public class ChallengeResourceFilterAttributeTests
+{
+ [TestMethod]
+ public void ExecuteFilter_NoSecretValue_RedirectsToChallenge()
+ {
+ var filter = new ChallengeResourceFilterAttribute(NullLogger.Instance);
+
+ var httpContext = new DefaultHttpContext
+ {
+ Request =
+ {
+ Scheme = "https",
+ Host = new HostString("localhost"),
+ Path = "/start"
+ }
+ };
+
+ var actionContext = new ActionContext(httpContext,
+ new RouteData(),
+ new ActionDescriptor(),
+ new ModelStateDictionary());
+
+ var resourceExecutingContext = new ResourceExecutingContext(actionContext,
+ new List(),
+ new List());
+
+ filter.OnResourceExecuting(resourceExecutingContext);
+
+ var result = resourceExecutingContext.Result;
+
+ result.Should().BeAssignableTo();
+
+ var redirect = (RedirectToActionResult)result!;
+
+ redirect.ActionName.Should().Be("Index");
+ redirect.ControllerName.Should().Be("Challenge");
+ redirect.RouteValues.Should().ContainKey("redirectAddress");
+ redirect.Permanent.Should().BeFalse();
+ redirect.PreserveMethod.Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void ExecuteFilter_NoSecretValue_LogsWarning()
+ {
+ var mockLogger = new Mock>();
+
+ var filter = new ChallengeResourceFilterAttribute(mockLogger.Object);
+
+ var httpContext = new DefaultHttpContext
+ {
+ Request =
+ {
+ Scheme = "https",
+ Host = new HostString("localhost"),
+ Path = "/start"
+ }
+ };
+
+ var actionContext = new ActionContext(httpContext,
+ new RouteData(),
+ new ActionDescriptor(),
+ new ModelStateDictionary());
+
+ var resourceExecutingContext = new ResourceExecutingContext(actionContext,
+ new List(),
+ new List());
+
+ filter.OnResourceExecuting(resourceExecutingContext);
+
+ mockLogger.VerifyWarning($"Access denied by {nameof(ChallengeResourceFilterAttribute)}");
+ }
+
+ [TestMethod]
+ public void ExecuteFilter_CorrectSecretValue_PassesThrough()
+ {
+ var filter = new ChallengeResourceFilterAttribute(NullLogger.Instance);
+
+ var httpContext = new DefaultHttpContext
+ {
+ Request =
+ {
+ Scheme = "https",
+ Host = new HostString("localhost"),
+ Path = "/start"
+ }
+ };
+
+ var cookie = new[]
+ {
+ $"{ChallengeResourceFilterAttribute.AuthSecretCookieName}={ChallengeResourceFilterAttribute.Challenge}"
+ };
+
+ httpContext.Request.Headers["Cookie"] = cookie;
+
+ var actionContext = new ActionContext(httpContext,
+ new RouteData(),
+ new ActionDescriptor(),
+ new ModelStateDictionary());
+
+ var resourceExecutingContext = new ResourceExecutingContext(actionContext,
+ new List(),
+ new List());
+
+ filter.OnResourceExecuting(resourceExecutingContext);
+
+ resourceExecutingContext.Result.Should().BeNull();
+ }
+
+ [TestMethod]
+ public void ExecuteFilter_IncorrectSecretValue_RedirectsToChallenge()
+ {
+ var filter = new ChallengeResourceFilterAttribute(NullLogger.Instance);
+
+ var httpContext = new DefaultHttpContext
+ {
+ Request =
+ {
+ Scheme = "https",
+ Host = new HostString("localhost"),
+ Path = "/start"
+ }
+ };
+
+ var cookie = new[]
+ {
+ $"{ChallengeResourceFilterAttribute.AuthSecretCookieName}=not-{ChallengeResourceFilterAttribute.Challenge}"
+ };
+
+ httpContext.Request.Headers["Cookie"] = cookie;
+
+ var actionContext = new ActionContext(httpContext,
+ new RouteData(),
+ new ActionDescriptor(),
+ new ModelStateDictionary());
+
+ var resourceExecutingContext = new ResourceExecutingContext(actionContext,
+ new List(),
+ new List());
+
+ filter.OnResourceExecuting(resourceExecutingContext);
+
+ var result = resourceExecutingContext.Result;
+
+ result.Should().BeAssignableTo();
+
+ var redirect = (RedirectToActionResult)result!;
+
+ redirect.ActionName.Should().Be("Index");
+ redirect.ControllerName.Should().Be("Challenge");
+ redirect.RouteValues.Should().ContainKey("redirectAddress");
+ redirect.Permanent.Should().BeFalse();
+ redirect.PreserveMethod.Should().BeFalse();
+ }
+
+ [TestMethod]
+ public void ExecuteFilter_IncorrectSecretValue_LogsWarning()
+ {
+ var logger = new Mock>();
+
+ var filter = new ChallengeResourceFilterAttribute(logger.Object);
+
+ var httpContext = new DefaultHttpContext
+ {
+ Request =
+ {
+ Scheme = "https",
+ Host = new HostString("localhost"),
+ Path = "/start"
+ }
+ };
+
+ var cookie = new[]
+ {
+ $"{ChallengeResourceFilterAttribute.AuthSecretCookieName}=not-{ChallengeResourceFilterAttribute.Challenge}"
+ };
+
+ httpContext.Request.Headers["Cookie"] = cookie;
+
+ var actionContext = new ActionContext(httpContext,
+ new RouteData(),
+ new ActionDescriptor(),
+ new ModelStateDictionary());
+
+ var resourceExecutingContext = new ResourceExecutingContext(actionContext,
+ new List(),
+ new List());
+
+ filter.OnResourceExecuting(resourceExecutingContext);
+
+ logger.VerifyWarning($"Access denied by {nameof(ChallengeResourceFilterAttribute)} (incorrect value submitted)");
+ }
+}
\ No newline at end of file
From 551496a58f3a4b03438cbe52fb32c6ed85c3fa9b Mon Sep 17 00:00:00 2001
From: RobertGHippo
Date: Fri, 7 Jun 2024 18:33:55 +0100
Subject: [PATCH 04/28] Fix assertion
---
.../Controllers/ChallengeControllerTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs
index 825867aa..2fc97411 100644
--- a/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs
+++ b/tests/Dfe.EarlyYearsQualification.UnitTests/Controllers/ChallengeControllerTests.cs
@@ -65,7 +65,7 @@ public async Task GetChallenge_NonLocalFrom_Returns_Page_With_BaseFrom()
content.Model.Should().BeAssignableTo()
.Which
- .RedirectAddress.Should().Be(from);
+ .RedirectAddress.Should().Be("/");
}
[TestMethod]
From b9e66210155bbd178ff1fda446a346ce333e2010 Mon Sep 17 00:00:00 2001
From: RobertGHippo
Date: Mon, 10 Jun 2024 14:25:59 +0100
Subject: [PATCH 05/28] Converted raw form to cshtml form helper. Only relative
path addresses are considered "local" URLs.
---
.../Filters/ChallengeResourceFilterAttribute.cs | 5 ++---
.../Views/Challenge/EntryForm.cshtml | 7 ++++---
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs b/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
index 7c1e4a8a..f663ab37 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
+++ b/src/Dfe.EarlyYearsQualification.Web/Filters/ChallengeResourceFilterAttribute.cs
@@ -1,4 +1,3 @@
-using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
@@ -36,12 +35,12 @@ public void OnResourceExecuting(ResourceExecutingContext context)
logger.LogWarning(warningMessage);
- var requestedUri = context.HttpContext.Request.GetEncodedUrl();
+ var requestedPath = context.HttpContext.Request.Path;
context.Result = new RedirectToActionResult("Index", "Challenge",
new
{
- redirectAddress = requestedUri
+ redirectAddress = requestedPath
},
RedirectIsPermanent,
RedirectPreservesMethod);
diff --git a/src/Dfe.EarlyYearsQualification.Web/Views/Challenge/EntryForm.cshtml b/src/Dfe.EarlyYearsQualification.Web/Views/Challenge/EntryForm.cshtml
index dc0d728c..ed70ddde 100644
--- a/src/Dfe.EarlyYearsQualification.Web/Views/Challenge/EntryForm.cshtml
+++ b/src/Dfe.EarlyYearsQualification.Web/Views/Challenge/EntryForm.cshtml
@@ -15,10 +15,11 @@
If you have been invited to view this preview service, you will have been
given a value that will grant access. Please type in the value and click 'Submit'.
-
+}