From c7bda8eb280bac55ea67ddbe1da0933dd1654f92 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 8 Jan 2023 16:19:25 -0500 Subject: [PATCH] WIP on log on - Add anti-CSRF tokens to forms - WIP on toast notifications --- src/JobsJobsJobs/Data/Data.fs | 9 +- src/JobsJobsJobs/Server/Handlers.fs | 120 ++++++++-------------- src/JobsJobsJobs/Server/ViewModels.fs | 1 + src/JobsJobsJobs/Server/Views/Citizen.fs | 78 +++++++++++++- src/JobsJobsJobs/Server/Views/Common.fs | 5 + src/JobsJobsJobs/Server/wwwroot/script.js | 74 ++++++++++++- 6 files changed, 201 insertions(+), 86 deletions(-) diff --git a/src/JobsJobsJobs/Data/Data.fs b/src/JobsJobsJobs/Data/Data.fs index ede506d..f8d398f 100644 --- a/src/JobsJobsJobs/Data/Data.fs +++ b/src/JobsJobsJobs/Data/Data.fs @@ -39,7 +39,7 @@ module DataConnection = /// The data source for the document store let mutable private dataSource : NpgsqlDataSource option = None - /// Get the connection string + /// Get a connection let connection () = match dataSource with | Some ds -> ds.OpenConnection () |> Sql.existingConnection @@ -212,10 +212,9 @@ module Citizens = /// Register a citizen (saves citizen and security settings); returns false if the e-mail is already taken let register citizen (security : SecurityInfo) = backgroundTask { - let connProps = connection () - use conn = Sql.createConnection connProps - do! conn.OpenAsync () - use! txn = conn.BeginTransactionAsync () + let connProps = connection () + use conn = Sql.createConnection connProps + use! txn = conn.BeginTransactionAsync () try do! saveCitizen citizen connProps do! saveSecurity security connProps diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 525413e..ce41142 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -58,7 +58,9 @@ module Helpers = open System.Security.Claims open Giraffe.Htmx + open Microsoft.AspNetCore.Antiforgery open Microsoft.Extensions.Configuration + open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Options /// Get the NodaTime clock from the request context @@ -99,13 +101,20 @@ module Helpers = // -- NEW -- + let antiForgery (ctx : HttpContext) = + ctx.RequestServices.GetRequiredService () + + /// Obtain an anti-forgery token set + let csrf ctx = + (antiForgery ctx).GetAndStoreTokens ctx + /// Add a message to the response let sendMessage (msg : string) : HttpHandler = setHttpHeader "X-Message" msg /// Add an error message to the response let sendError (msg : string) : HttpHandler = - sendMessage $"ERROR|||{msg}" + sendMessage $"error|||{msg}" /// Render a page-level view let render pageTitle content : HttpHandler = fun _ ctx -> task { @@ -119,6 +128,13 @@ module Helpers = return! ctx.WriteHtmlViewAsync (renderFunc renderCtx) } + /// Validate the anti cross-site request forgery token in the current request + let validateCsrf : HttpHandler = fun next ctx -> task { + match! (antiForgery ctx).IsRequestValidAsync ctx with + | true -> return! next ctx + | false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" earlyReturn ctx + } + open System open JobsJobsJobs.Data @@ -165,8 +181,24 @@ module Citizen = } // GET: /citizen/log-on - let logOn : HttpHandler = - render "Log On" (Citizen.logOn { ErrorMessage = None; Email = ""; Password = "" }) + let logOn : HttpHandler = fun next ctx -> + render "Log On" (Citizen.logOn { ErrorMessage = None; Email = ""; Password = "" } (csrf ctx)) next ctx + + // POST: /citizen/log-on + // TODO: convert + let doLogOn = validateCsrf >=> fun next ctx -> task { + let! form = ctx.BindJsonAsync () + + match! Citizens.tryLogOn form.Email form.Password Auth.Passwords.verify Auth.Passwords.hash (now ctx) with + | Ok citizen -> + return! + json + { Jwt = Auth.createJwt citizen (authConfig ctx) + CitizenId = CitizenId.toString citizen.Id + Name = Citizen.name citizen + } next ctx + | Error msg -> return! RequestErrors.BAD_REQUEST msg next ctx + } // GET: /citizen/register let register : HttpHandler = fun next ctx -> @@ -178,11 +210,11 @@ module Citizen = let qAndA = Support.questions ctx render "Register" (Citizen.register (fst qAndA[q1Index]) (fst qAndA[q2Index]) - { RegisterViewModel.empty with Question1Index = q1Index; Question2Index = q2Index }) next ctx + { RegisterViewModel.empty with Question1Index = q1Index; Question2Index = q2Index } (csrf ctx)) next ctx // POST: /citizen/register - let doRegistration : HttpHandler = fun next ctx -> task { + let doRegistration = validateCsrf >=> fun next ctx -> task { let! form = ctx.BindFormAsync () let qAndA = Support.questions ctx let mutable badForm = false @@ -202,7 +234,7 @@ module Citizen = let refreshPage () = render "Register" (Citizen.register (fst qAndA[form.Question1Index]) (fst qAndA[form.Question2Index]) - { form with Password = "" }) + { form with Password = "" } (csrf ctx)) if badForm then let handle = sendError "The form posted was invalid; please complete it again" >=> register return! handle next ctx @@ -247,71 +279,6 @@ module Citizen = [] module CitizenApi = - // POST: /api/citizen/register - let register : HttpHandler = fun next ctx -> task { - let! form = ctx.BindJsonAsync () - if form.Password.Length < 8 || form.ConfirmPassword.Length < 8 || form.Password <> form.ConfirmPassword then - return! RequestErrors.BAD_REQUEST "Password out of range" next ctx - else - let now = now ctx - let noPass = - { Citizen.empty with - Id = CitizenId.create () - Email = form.Email - FirstName = form.FirstName - LastName = form.LastName - DisplayName = noneIfBlank form.DisplayName - JoinedOn = now - LastSeenOn = now - } - let citizen = { noPass with PasswordHash = Auth.Passwords.hash noPass form.Password } - let security = - { SecurityInfo.empty with - Id = citizen.Id - AccountLocked = true - Token = Some (Auth.createToken citizen) - TokenUsage = Some "confirm" - TokenExpires = Some (now + (Duration.FromDays 3)) - } - let! success = Citizens.register citizen security - if success then - let! emailResponse = Email.sendAccountConfirmation citizen security - let logFac = logger ctx - let log = logFac.CreateLogger "JobsJobsJobs.Api.Handlers.Citizen" - log.LogInformation $"Confirmation e-mail for {citizen.Email} received {emailResponse}" - return! ok next ctx - else - return! RequestErrors.CONFLICT "" next ctx - } - - // PATCH: /api/citizen/confirm - let confirmToken : HttpHandler = fun next ctx -> task { - let! form = ctx.BindJsonAsync<{| token : string |}> () - let! valid = Citizens.confirmAccount form.token - return! json {| Valid = valid |} next ctx - } - - // DELETE: /api/citizen/deny - let denyToken : HttpHandler = fun next ctx -> task { - let! form = ctx.BindJsonAsync<{| token : string |}> () - let! valid = Citizens.denyAccount form.token - return! json {| Valid = valid |} next ctx - } - - // POST: /api/citizen/log-on - let logOn : HttpHandler = fun next ctx -> task { - let! form = ctx.BindJsonAsync () - match! Citizens.tryLogOn form.Email form.Password Auth.Passwords.verify Auth.Passwords.hash (now ctx) with - | Ok citizen -> - return! - json - { Jwt = Auth.createJwt citizen (authConfig ctx) - CitizenId = CitizenId.toString citizen.Id - Name = Citizen.name citizen - } next ctx - | Error msg -> return! RequestErrors.BAD_REQUEST msg next ctx - } - // GET: /api/citizen/[id] let get citizenId : HttpHandler = authorize >=> fun next ctx -> task { match! Citizens.findById (CitizenId citizenId) with @@ -645,7 +612,10 @@ let allEndpoints = [ route "/log-on" Citizen.logOn route "/register" Citizen.register ] - POST [ route "/register" Citizen.doRegistration ] + POST [ + route "/log-on" Citizen.doLogOn + route "/register" Citizen.doRegistration + ] ] GET_HEAD [ route "/how-it-works" Home.howItWorks ] GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ] @@ -655,15 +625,9 @@ let allEndpoints = [ GET_HEAD [ routef "/%O" CitizenApi.get ] PATCH [ route "/account" CitizenApi.account - route "/confirm" CitizenApi.confirmToken - ] - POST [ - route "/log-on" CitizenApi.logOn - route "/register" CitizenApi.register ] DELETE [ route "" CitizenApi.delete - route "/deny" CitizenApi.denyToken ] ] GET_HEAD [ route "/continents" Continent.all ] diff --git a/src/JobsJobsJobs/Server/ViewModels.fs b/src/JobsJobsJobs/Server/ViewModels.fs index 061a83b..5da311b 100644 --- a/src/JobsJobsJobs/Server/ViewModels.fs +++ b/src/JobsJobsJobs/Server/ViewModels.fs @@ -15,6 +15,7 @@ type LogOnViewModel = /// View model for the registration page +[] type RegisterViewModel = { /// The user's first name FirstName : string diff --git a/src/JobsJobsJobs/Server/Views/Citizen.fs b/src/JobsJobsJobs/Server/Views/Citizen.fs index ff196c1..963c0d6 100644 --- a/src/JobsJobsJobs/Server/Views/Citizen.fs +++ b/src/JobsJobsJobs/Server/Views/Citizen.fs @@ -4,6 +4,7 @@ module JobsJobsJobs.Views.Citizen open Giraffe.ViewEngine open Giraffe.ViewEngine.Htmx +open JobsJobsJobs.Domain open JobsJobsJobs.ViewModels /// The account confirmation page @@ -21,6 +22,77 @@ let confirmAccount isConfirmed = ] ] +/// The citizen's dashboard page +let dashboard (citizen : Citizen) (profile : Profile option) profileCount = + article [ _class "container" ] [ + h3 [ _class "pb-4" ] [ rawText "ITM, "; str citizen.FirstName; rawText "!" ] + div [ _class "row row-cols-1 row-cols-md-2" ] [ + div [ _class "col" ] [ + div [ _class "card h-100" ] [ + h5 [ _class "card-header" ] [ rawText "Your Profile" ] + div [ _class "card-body" ] [ + match profile with + | Some prfl -> + h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [ + rawText "Last updated "; (* full-date-time :date="profile.lastUpdatedOn" *) + ] + p [ _class "card-text" ] [ + rawText "Your profile currently lists "; str $"{List.length prfl.Skills}" + rawText " skill"; rawText (if List.length prfl.Skills <> 1 then "s" else "") + rawText "." + if prfl.IsSeekingEmployment then + br []; br [] + rawText "Your profile indicates that you are seeking employment. Once you find it, " + a [ _href "/success-story/add" ] [ rawText "tell your fellow citizens about it!" ] + ] + | None -> + p [ _class "card-text" ] [ + rawText "You do not have an employment profile established; click below (or " + rawText "“Edit Profile” in the menu) to get started!" + ] + ] + div [ _class "card-footer" ] [ + match profile with + | Some p -> + a [ _href $"/profile/{citizen.Id}/view"; _class "btn btn-outline-secondary" ] [ + rawText "View Profile" + ]; rawText "   " + a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ rawText "Edit Profile" ] + | None -> + a [ _href "/profile/edit"; _class "btn btn-primary" ] [ rawText "Create Profile" ] + ] + ] + ] + div [ _class "col" ] [ + div [ _class "card h-100" ] [ + h5 [ _class "card-header" ] [ rawText "Other Citizens" ] + div [ _class "card-body" ] [ + h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [ + rawText (if profileCount = 0 then "No" else $"{profileCount} Total") + rawText " Employment Profile"; rawText (if profileCount <> 1 then "s" else "") + ] + p [ _class "card-text" ] [ + if profileCount = 1 && Option.isSome profile then + "It looks like, for now, it’s just you…" + else if profileCount > 0 then "Take a look around and see if you can help them find work!" + else "You can click below, but you will not find anything…" + |> rawText + ] + ] + div [ _class "card-footer" ] [ + a [ _href "/profile/search"; _class "btn btn-outline-secondary" ] [ rawText "Search Profiles" ] + ] + ] + ] + ] + p [] [ rawText " " ] + p [] [ + rawText "To see how this application works, check out “How It Works” in the sidebar (last " + rawText "updated August 29th, 2021)." + ] + ] + + /// The account denial page let denyAccount wasDeleted = article [] [ @@ -35,7 +107,7 @@ let denyAccount wasDeleted = ] /// The log on page -let logOn (m : LogOnViewModel) = +let logOn (m : LogOnViewModel) csrf = article [] [ h3 [ _class "pb-3" ] [ rawText "Log On" ] match m.ErrorMessage with @@ -50,6 +122,7 @@ let logOn (m : LogOnViewModel) = ] | None -> () form [ _class "row g-3 pb-3"; _hxPost "/citizen/log-on" ] [ + antiForgery csrf div [ _class "col-12 col-md-6" ] [ div [ _class "form-floating" ] [ input [ _type "email" @@ -89,10 +162,11 @@ let logOn (m : LogOnViewModel) = ] /// The registration page -let register q1 q2 (m : RegisterViewModel) = +let register q1 q2 (m : RegisterViewModel) csrf = article [] [ h3 [ _class "pb-3" ] [ rawText "Register" ] form [ _class "row g-3"; _hxPost "/citizen/register" ] [ + antiForgery csrf div [ _class "col-6 col-xl-4" ] [ div [ _class "form-floating" ] [ input [ _type "text"; _class "form-control"; _id (nameof m.FirstName); _name (nameof m.FirstName) diff --git a/src/JobsJobsJobs/Server/Views/Common.fs b/src/JobsJobsJobs/Server/Views/Common.fs index 066a78c..a64bbd6 100644 --- a/src/JobsJobsJobs/Server/Views/Common.fs +++ b/src/JobsJobsJobs/Server/Views/Common.fs @@ -2,9 +2,14 @@ module JobsJobsJobs.Views.Common open Giraffe.ViewEngine +open Microsoft.AspNetCore.Antiforgery /// Create an audio clip with the specified text node let audioClip clip text = span [ _class "jjj-audio-clip"; _onclick "jjj.playFile(this)" ] [ text; audio [ _id clip ] [ source [ _src $"/audio/{clip}.mp3" ] ] ] + +/// Create an anti-forgery hidden input +let antiForgery (csrf : AntiforgeryTokenSet) = + input [ _type "hidden"; _name csrf.FormFieldName; _value csrf.RequestToken ] diff --git a/src/JobsJobsJobs/Server/wwwroot/script.js b/src/JobsJobsJobs/Server/wwwroot/script.js index 3c6df25..635707a 100644 --- a/src/JobsJobsJobs/Server/wwwroot/script.js +++ b/src/JobsJobsJobs/Server/wwwroot/script.js @@ -15,5 +15,77 @@ this.jjj = { /** @type {HTMLElement} */ const menu = document.querySelector(".jjj-mobile-menu") if (menu.style.display !== "none") bootstrap.Offcanvas.getOrCreateInstance(menu).hide() - } + }, + + /** + * Show a message via toast + * @param {string} message The message to show + */ + showToast (message) { + const [level, msg] = message.split("|||") + + let header + if (level !== "success") { + const heading = typ => `${typ.toUpperCase()}` + + header = document.createElement("div") + header.className = "toast-header" + header.innerHTML = heading(level === "warning" ? level : "error") + + const close = document.createElement("button") + close.type = "button" + close.className = "btn-close" + close.setAttribute("data-bs-dismiss", "toast") + close.setAttribute("aria-label", "Close") + header.appendChild(close) + } + + const body = document.createElement("div") + body.className = "toast-body" + body.innerText = msg + + const toastEl = document.createElement("div") + toastEl.className = `toast bg-${level === "error" ? "danger" : level} text-white` + toastEl.setAttribute("role", "alert") + toastEl.setAttribute("aria-live", "assertlive") + toastEl.setAttribute("aria-atomic", "true") + toastEl.addEventListener("hidden.bs.toast", e => e.target.remove()) + if (header) toastEl.appendChild(header) + + toastEl.appendChild(body) + document.getElementById("toasts").appendChild(toastEl) + new bootstrap.Toast(toastEl, { autohide: level === "success" }).show() + }, + + /** + * The time zone of the current browser + * @type {string} + **/ + timeZone: undefined, + + /** + * Derive the time zone from the current browser + */ + deriveTimeZone () { + try { + this.timeZone = (new Intl.DateTimeFormat()).resolvedOptions().timeZone + } catch (_) { } + } } + +htmx.on("htmx:afterOnLoad", function (evt) { + const hdrs = evt.detail.xhr.getAllResponseHeaders() + // Show a message if there was one in the response + if (hdrs.indexOf("x-toast") >= 0) { + jjj.showToast(evt.detail.xhr.getResponseHeader("x-toast")) + } +}) + +htmx.on("htmx:configRequest", function (evt) { + // Send the user's current time zone so that we can display local time + if (jjj.timeZone) { + evt.detail.headers["X-Time-Zone"] = jjj.timeZone + } +}) + +jjj.deriveTimeZone()