Skip to content

Commit

Permalink
Merge pull request #182 from DFE-Digital/feature/eyqb-319-no-public-a…
Browse files Browse the repository at this point in the history
…ccess

Guard the public service behind configurable secrets with a challenge page
  • Loading branch information
RobertGHippo authored Jun 14, 2024
2 parents 879142f + 35fde42 commit 9367fb2
Show file tree
Hide file tree
Showing 50 changed files with 1,239 additions and 106 deletions.
5 changes: 4 additions & 1 deletion .github/actions/run-accessibility-tests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ inputs:
url:
required: true
type: string
auth_secret:
required: true
type: string

runs:
using: composite

steps:
- name: Run Pa11y
shell: bash
run: npm install -g pa11y-ci && pa11y-ci ${{ inputs.url }} --config .pa11yci-ubuntu.json
run: npm install -g pa11y-ci && export AUTH_SECRET=${{ inputs.auth_secret }} && pa11y-ci ${{ inputs.url }} --config .pa11yci-ubuntu.js
working-directory: ./tests/Dfe.EarlyYearsQualification.AccessibilityTests/


11 changes: 10 additions & 1 deletion .github/actions/run-app-for-testing/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@ description: Run the app within the action so we can run E2E and Accessibility t

inputs:
url:
description: "URL used by the app to listen"
required: true
type: string
auth_secret:
description: "Auth secret used by end-to-end tests"
required: true
type: string


runs:
using: composite

steps:
- name: Run .Net Project
shell: bash
run: dotnet run --urls "${{ inputs.url }}" --project="src/Dfe.EarlyYearsQualification.Web" --UseMockContentful=true &
run: |
dotnet run --urls "${{ inputs.url }}" --project="src/Dfe.EarlyYearsQualification.Web" \
--UseMockContentful=true \
--ServiceAccess:Keys:0="${{ inputs.auth_secret }}" &
7 changes: 6 additions & 1 deletion .github/actions/run-e2e-tests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ inputs:
url:
required: true
type: string
auth_secret:
required: true
type: string

runs:
using: composite
Expand All @@ -14,6 +17,7 @@ runs:
uses: cypress-io/github-action@v6
if: success() || failure()
with:
env: auth_secret="${{ inputs.auth_secret }}"
working-directory: ./tests/Dfe.EarlyYearsQualification.E2ETests/
browser: chrome
config: baseUrl="${{ inputs.url }}"
Expand All @@ -22,12 +26,13 @@ runs:
uses: cypress-io/github-action@v6
if: success() || failure()
with:
env: auth_secret="${{ inputs.auth_secret }}"
working-directory: ./tests/Dfe.EarlyYearsQualification.E2ETests/
browser: firefox
config: baseUrl="${{ inputs.url }}"

- name: Store screenshots on test failure
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/code-pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,16 @@ jobs:
uses: ./.github/actions/run-app-for-testing
with:
url: ${{ env.URL }}
auth_secret: ${{ secrets.WEBAPP_E2E_ACCESS_KEY }}

- name: Run e2e tests
uses: ./.github/actions/run-e2e-tests
with:
url: ${{ env.URL }}
auth_secret: ${{ secrets.WEBAPP_E2E_ACCESS_KEY }}

- name: Run Accessibility tests
uses: ./.github/actions/run-accessibility-tests
with:
url: ${{ env.URL }}
auth_secret: ${{ secrets.WEBAPP_E2E_ACCESS_KEY }}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -11,7 +12,7 @@ public class AccessibilityStatementController(
ILogger<AccessibilityStatementController> logger,
IContentService contentService,
IHtmlRenderer renderer)
: Controller
: ServiceController
{
[HttpGet]
public async Task<IActionResult> Index()
Expand All @@ -21,7 +22,7 @@ public async Task<IActionResult> Index()
if (content is null)
{
logger.LogError("No content for the accessibility statement page");
return RedirectToAction("Error", "Home");
return RedirectToAction("Index", "Error");
}

var model = await Map(content);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
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;

namespace Dfe.EarlyYearsQualification.Web.Controllers;

[Route("/advice")]
public class AdviceController(ILogger<AdviceController> logger, IContentService contentService, IHtmlRenderer renderer)
: Controller
: ServiceController
{
[HttpGet("qualification-outside-the-united-kingdom")]
public async Task<IActionResult> QualificationOutsideTheUnitedKingdom()
Expand All @@ -22,8 +23,8 @@ private async Task<IActionResult> GetView(string advicePageId)
var advicePage = await contentService.GetAdvicePage(advicePageId);
if (advicePage is null)
{
logger.LogError("No content for the advice page");
return RedirectToAction("Error", "Home");
logger.LogError("No content for the advice page");
return RedirectToAction("Index", "Error");
}

var model = await Map(advicePage);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Dfe.EarlyYearsQualification.Web.Filters;
using Microsoft.AspNetCore.Mvc;

namespace Dfe.EarlyYearsQualification.Web.Controllers.Base;

/// <summary>
/// Controller class that is guarded by a <see cref="IChallengeResourceFilterAttribute" />
/// so that it is possible, while in private beta and in non-production environments,
/// to configure a secret that must be entered to gain access to the service.
/// All controllers except <see cref="ChallengeController" />, <see cref="ErrorController" />
/// and <see cref="HealthController" /> should derive from this type.
/// </summary>
[ServiceFilter<IChallengeResourceFilterAttribute>]
public class ServiceController : Controller;
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Dfe.EarlyYearsQualification.Web.Filters;
using Dfe.EarlyYearsQualification.Web.Models;
using Microsoft.AspNetCore.Mvc;

namespace Dfe.EarlyYearsQualification.Web.Controllers;

public class ChallengeController(
ILogger<ChallengeController> logger,
IUrlHelper urlHelper)
: Controller
{
private const string DefaultRedirectAddress = "/";

[HttpGet]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public Task<IActionResult> Index([FromQuery] ChallengeModel model)
{
if (!ModelState.IsValid)
{
logger.LogWarning("Invalid challenge model (get)");
}

model.RedirectAddress = SanitiseReferralAddress(model.RedirectAddress);

logger.LogWarning("Challenge page invoked");

return Task.FromResult<IActionResult>(View("EntryForm", model));
}

[HttpPost]
public Task<IActionResult> 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");

SetAuthSecretCookie(model.Value);
return Task.FromResult<IActionResult>(new RedirectResult(referralAddress));
}

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 redirectAddress;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,7 +16,7 @@ public class CookiesController(
ISuccessBannerRenderer successBannerRenderer,
ICookieService cookieService,
IUrlHelper urlHelper)
: Controller
: ServiceController
{
[HttpGet]
public async Task<IActionResult> Index()
Expand All @@ -25,7 +26,7 @@ public async Task<IActionResult> Index()
if (content is null)
{
logger.LogError("No content for the cookies page");
return RedirectToAction("Error", "Home");
return RedirectToAction("Index", "Error");
}

var model = await Map(content);
Expand All @@ -34,21 +35,21 @@ public async Task<IActionResult> 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));
Expand All @@ -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<CookiesPageModel> Map(CookiesPage content)
Expand Down
16 changes: 16 additions & 0 deletions src/Dfe.EarlyYearsQualification.Web/Controllers/ErrorController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Diagnostics;
using Dfe.EarlyYearsQualification.Web.Models;
using Microsoft.AspNetCore.Mvc;

namespace Dfe.EarlyYearsQualification.Web.Controllers;

[Route("/error")]
public class ErrorController : Controller
{
[HttpGet]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Index()
{
return View("Error", new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
}
13 changes: 3 additions & 10 deletions src/Dfe.EarlyYearsQualification.Web/Controllers/HomeController.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
using System.Diagnostics;
using Dfe.EarlyYearsQualification.Content.Entities;
using Dfe.EarlyYearsQualification.Content.Renderers.Entities;
using Dfe.EarlyYearsQualification.Content.Services;
using Dfe.EarlyYearsQualification.Web.Models;
using Dfe.EarlyYearsQualification.Web.Controllers.Base;
using Dfe.EarlyYearsQualification.Web.Models.Content;
using Microsoft.AspNetCore.Mvc;

Expand All @@ -13,7 +12,7 @@ public class HomeController(
IContentService contentService,
IHtmlRenderer htmlRenderer,
ISideContentRenderer sideContentRenderer)
: Controller
: ServiceController
{
[HttpGet]
public async Task<IActionResult> Index()
Expand All @@ -22,19 +21,13 @@ public async Task<IActionResult> Index()
if (startPageContent is null)
{
logger.LogCritical("Start page content not found");
return RedirectToAction("Error");
return RedirectToAction("Index", "Error");
}

var model = await Map(startPageContent);
return View(model);
}

[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}

private async Task<StartPageModel> Map(StartPage startPageContent)
{
return new StartPageModel
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -12,7 +13,7 @@ public class QualificationDetailsController(
ILogger<QualificationDetailsController> logger,
IContentService contentService,
IGovUkInsetTextRenderer renderer)
: Controller
: ServiceController
{
[HttpGet]
public IActionResult Get()
Expand All @@ -32,7 +33,7 @@ public async Task<IActionResult> Index(string qualificationId)
if (detailsPageContent is null)
{
logger.LogError("No content for the qualification details page");
return RedirectToAction("Error", "Home");
return RedirectToAction("Index", "Error");
}

var qualification = await contentService.GetQualificationById(qualificationId);
Expand All @@ -42,7 +43,7 @@ public async Task<IActionResult> Index(string qualificationId)
logger.LogError("Could not find details for qualification with ID: {QualificationId}",
loggedQualificationId);

return RedirectToAction("Error", "Home");
return RedirectToAction("Index", "Error");
}

var model = await Map(qualification, detailsPageContent);
Expand Down
Loading

0 comments on commit 9367fb2

Please sign in to comment.