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
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

View File

@ -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<IAntiforgery> ()
/// 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<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
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<RegisterViewModel> ()
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 =
[<RequireQualifiedAccess>]
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]
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 ]

View File

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

View File

@ -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 "&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
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)

View File

@ -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 ]

View File

@ -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 => `<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()