WIP on log on
- Add anti-CSRF tokens to forms - WIP on toast notifications
This commit is contained in:
parent
663c37b642
commit
c7bda8eb28
|
@ -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
|
||||
|
|
|
@ -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 ]
|
||||
|
|
|
@ -15,6 +15,7 @@ type LogOnViewModel =
|
|||
|
||||
|
||||
/// View model for the registration page
|
||||
[<CLIMutable>]
|
||||
type RegisterViewModel =
|
||||
{ /// The user's first name
|
||||
FirstName : string
|
||||
|
|
|
@ -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 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)
|
||||
|
|
|
@ -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 ]
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue
Block a user