WIP on log on

- Add anti-CSRF tokens to forms
- WIP on toast notifications
This commit is contained in:
Daniel J. Summers 2023-01-08 16:19:25 -05:00
parent 663c37b642
commit c7bda8eb28
6 changed files with 201 additions and 86 deletions

View File

@ -39,7 +39,7 @@ module DataConnection =
/// The data source for the document store /// The data source for the document store
let mutable private dataSource : NpgsqlDataSource option = None let mutable private dataSource : NpgsqlDataSource option = None
/// Get the connection string /// Get a connection
let connection () = let connection () =
match dataSource with match dataSource with
| Some ds -> ds.OpenConnection () |> Sql.existingConnection | 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 /// Register a citizen (saves citizen and security settings); returns false if the e-mail is already taken
let register citizen (security : SecurityInfo) = backgroundTask { let register citizen (security : SecurityInfo) = backgroundTask {
let connProps = connection () let connProps = connection ()
use conn = Sql.createConnection connProps use conn = Sql.createConnection connProps
do! conn.OpenAsync () use! txn = conn.BeginTransactionAsync ()
use! txn = conn.BeginTransactionAsync ()
try try
do! saveCitizen citizen connProps do! saveCitizen citizen connProps
do! saveSecurity security connProps do! saveSecurity security connProps

View File

@ -58,7 +58,9 @@ module Helpers =
open System.Security.Claims open System.Security.Claims
open Giraffe.Htmx open Giraffe.Htmx
open Microsoft.AspNetCore.Antiforgery
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Options open Microsoft.Extensions.Options
/// Get the NodaTime clock from the request context /// Get the NodaTime clock from the request context
@ -99,13 +101,20 @@ module Helpers =
// -- NEW -- // -- NEW --
let antiForgery (ctx : HttpContext) =
ctx.RequestServices.GetRequiredService<IAntiforgery> ()
/// Obtain an anti-forgery token set
let csrf ctx =
(antiForgery ctx).GetAndStoreTokens ctx
/// Add a message to the response /// Add a message to the response
let sendMessage (msg : string) : HttpHandler = let sendMessage (msg : string) : HttpHandler =
setHttpHeader "X-Message" msg setHttpHeader "X-Message" msg
/// Add an error message to the response /// Add an error message to the response
let sendError (msg : string) : HttpHandler = let sendError (msg : string) : HttpHandler =
sendMessage $"ERROR|||{msg}" sendMessage $"error|||{msg}"
/// Render a page-level view /// Render a page-level view
let render pageTitle content : HttpHandler = fun _ ctx -> task { let render pageTitle content : HttpHandler = fun _ ctx -> task {
@ -119,6 +128,13 @@ module Helpers =
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx) 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 System
open JobsJobsJobs.Data open JobsJobsJobs.Data
@ -165,8 +181,24 @@ module Citizen =
} }
// GET: /citizen/log-on // GET: /citizen/log-on
let logOn : HttpHandler = let logOn : HttpHandler = fun next ctx ->
render "Log On" (Citizen.logOn { ErrorMessage = None; Email = ""; Password = "" }) 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<LogOnForm> ()
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 // GET: /citizen/register
let register : HttpHandler = fun next ctx -> let register : HttpHandler = fun next ctx ->
@ -178,11 +210,11 @@ module Citizen =
let qAndA = Support.questions ctx let qAndA = Support.questions ctx
render "Register" render "Register"
(Citizen.register (fst qAndA[q1Index]) (fst qAndA[q2Index]) (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 // POST: /citizen/register
let doRegistration : HttpHandler = fun next ctx -> task { let doRegistration = validateCsrf >=> fun next ctx -> task {
let! form = ctx.BindFormAsync<RegisterViewModel> () let! form = ctx.BindFormAsync<RegisterViewModel> ()
let qAndA = Support.questions ctx let qAndA = Support.questions ctx
let mutable badForm = false let mutable badForm = false
@ -202,7 +234,7 @@ module Citizen =
let refreshPage () = let refreshPage () =
render "Register" render "Register"
(Citizen.register (fst qAndA[form.Question1Index]) (fst qAndA[form.Question2Index]) (Citizen.register (fst qAndA[form.Question1Index]) (fst qAndA[form.Question2Index])
{ form with Password = "" }) { form with Password = "" } (csrf ctx))
if badForm then if badForm then
let handle = sendError "The form posted was invalid; please complete it again" >=> register let handle = sendError "The form posted was invalid; please complete it again" >=> register
return! handle next ctx return! handle next ctx
@ -247,71 +279,6 @@ module Citizen =
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module CitizenApi = module CitizenApi =
// POST: /api/citizen/register
let register : HttpHandler = fun next ctx -> task {
let! form = ctx.BindJsonAsync<CitizenRegistrationForm> ()
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<LogOnForm> ()
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] // GET: /api/citizen/[id]
let get citizenId : HttpHandler = authorize >=> fun next ctx -> task { let get citizenId : HttpHandler = authorize >=> fun next ctx -> task {
match! Citizens.findById (CitizenId citizenId) with match! Citizens.findById (CitizenId citizenId) with
@ -645,7 +612,10 @@ let allEndpoints = [
route "/log-on" Citizen.logOn route "/log-on" Citizen.logOn
route "/register" Citizen.register 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 "/how-it-works" Home.howItWorks ]
GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ] GET_HEAD [ route "/privacy-policy" Home.privacyPolicy ]
@ -655,15 +625,9 @@ let allEndpoints = [
GET_HEAD [ routef "/%O" CitizenApi.get ] GET_HEAD [ routef "/%O" CitizenApi.get ]
PATCH [ PATCH [
route "/account" CitizenApi.account route "/account" CitizenApi.account
route "/confirm" CitizenApi.confirmToken
]
POST [
route "/log-on" CitizenApi.logOn
route "/register" CitizenApi.register
] ]
DELETE [ DELETE [
route "" CitizenApi.delete route "" CitizenApi.delete
route "/deny" CitizenApi.denyToken
] ]
] ]
GET_HEAD [ route "/continents" Continent.all ] GET_HEAD [ route "/continents" Continent.all ]

View File

@ -15,6 +15,7 @@ type LogOnViewModel =
/// View model for the registration page /// View model for the registration page
[<CLIMutable>]
type RegisterViewModel = type RegisterViewModel =
{ /// The user's first name { /// The user's first name
FirstName : string FirstName : string

View File

@ -4,6 +4,7 @@ module JobsJobsJobs.Views.Citizen
open Giraffe.ViewEngine open Giraffe.ViewEngine
open Giraffe.ViewEngine.Htmx open Giraffe.ViewEngine.Htmx
open JobsJobsJobs.Domain
open JobsJobsJobs.ViewModels open JobsJobsJobs.ViewModels
/// The account confirmation page /// 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 "&ldquo;Edit Profile&rdquo; 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 "&nbsp; &nbsp;"
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&rsquo;s just you&hellip;"
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&hellip;"
|> rawText
]
]
div [ _class "card-footer" ] [
a [ _href "/profile/search"; _class "btn btn-outline-secondary" ] [ rawText "Search Profiles" ]
]
]
]
]
p [] [ rawText "&nbsp;" ]
p [] [
rawText "To see how this application works, check out &ldquo;How It Works&rdquo; in the sidebar (last "
rawText "updated August 29<sup>th</sup>, 2021)."
]
]
/// The account denial page /// The account denial page
let denyAccount wasDeleted = let denyAccount wasDeleted =
article [] [ article [] [
@ -35,7 +107,7 @@ let denyAccount wasDeleted =
] ]
/// The log on page /// The log on page
let logOn (m : LogOnViewModel) = let logOn (m : LogOnViewModel) csrf =
article [] [ article [] [
h3 [ _class "pb-3" ] [ rawText "Log On" ] h3 [ _class "pb-3" ] [ rawText "Log On" ]
match m.ErrorMessage with match m.ErrorMessage with
@ -50,6 +122,7 @@ let logOn (m : LogOnViewModel) =
] ]
| None -> () | None -> ()
form [ _class "row g-3 pb-3"; _hxPost "/citizen/log-on" ] [ form [ _class "row g-3 pb-3"; _hxPost "/citizen/log-on" ] [
antiForgery csrf
div [ _class "col-12 col-md-6" ] [ div [ _class "col-12 col-md-6" ] [
div [ _class "form-floating" ] [ div [ _class "form-floating" ] [
input [ _type "email" input [ _type "email"
@ -89,10 +162,11 @@ let logOn (m : LogOnViewModel) =
] ]
/// The registration page /// The registration page
let register q1 q2 (m : RegisterViewModel) = let register q1 q2 (m : RegisterViewModel) csrf =
article [] [ article [] [
h3 [ _class "pb-3" ] [ rawText "Register" ] h3 [ _class "pb-3" ] [ rawText "Register" ]
form [ _class "row g-3"; _hxPost "/citizen/register" ] [ form [ _class "row g-3"; _hxPost "/citizen/register" ] [
antiForgery csrf
div [ _class "col-6 col-xl-4" ] [ div [ _class "col-6 col-xl-4" ] [
div [ _class "form-floating" ] [ div [ _class "form-floating" ] [
input [ _type "text"; _class "form-control"; _id (nameof m.FirstName); _name (nameof m.FirstName) input [ _type "text"; _class "form-control"; _id (nameof m.FirstName); _name (nameof m.FirstName)

View File

@ -2,9 +2,14 @@
module JobsJobsJobs.Views.Common module JobsJobsJobs.Views.Common
open Giraffe.ViewEngine open Giraffe.ViewEngine
open Microsoft.AspNetCore.Antiforgery
/// Create an audio clip with the specified text node /// Create an audio clip with the specified text node
let audioClip clip text = let audioClip clip text =
span [ _class "jjj-audio-clip"; _onclick "jjj.playFile(this)" ] [ span [ _class "jjj-audio-clip"; _onclick "jjj.playFile(this)" ] [
text; audio [ _id clip ] [ source [ _src $"/audio/{clip}.mp3" ] ] 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 ]

View File

@ -15,5 +15,77 @@ this.jjj = {
/** @type {HTMLElement} */ /** @type {HTMLElement} */
const menu = document.querySelector(".jjj-mobile-menu") const menu = document.querySelector(".jjj-mobile-menu")
if (menu.style.display !== "none") bootstrap.Offcanvas.getOrCreateInstance(menu).hide() 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 => `<span class="me-auto"><strong>${typ.toUpperCase()}</strong></span>`
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()