Version 3 (#40)

Code for version 3
This commit was merged in pull request #40.
This commit is contained in:
2023-02-02 18:47:28 -05:00
committed by GitHub
parent 323ea83594
commit f3a7b9ea93
126 changed files with 7136 additions and 29577 deletions

View File

@@ -0,0 +1,279 @@
module JobsJobsJobs.Citizens.Data
open JobsJobsJobs.Common.Data
open JobsJobsJobs.Domain
open NodaTime
open Npgsql.FSharp
/// The last time a token purge check was run
let mutable private lastPurge = Instant.MinValue
/// Lock access to the above
let private locker = obj ()
/// Delete a citizen by their ID using the given connection properties
let private doDeleteById citizenId connProps = backgroundTask {
let! _ =
connProps
|> Sql.query $"
DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @id;
DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @id;
DELETE FROM {Table.Citizen} WHERE id = @id"
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|> Sql.executeNonQueryAsync
()
}
/// Delete a citizen by their ID
let deleteById citizenId =
doDeleteById citizenId (dataSource ())
/// Save a citizen
let private saveCitizen (citizen : Citizen) connProps =
saveDocument Table.Citizen (CitizenId.toString citizen.Id) connProps (mkDoc citizen)
/// Save security information for a citizen
let private saveSecurity (security : SecurityInfo) connProps =
saveDocument Table.SecurityInfo (CitizenId.toString security.Id) connProps (mkDoc security)
/// Purge expired tokens
let private purgeExpiredTokens now = backgroundTask {
let connProps = dataSource ()
let! info =
Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" connProps
|> Sql.executeAsync toDocument<SecurityInfo>
for expired in info |> List.filter (fun it -> it.TokenExpires.Value < now) do
if expired.TokenUsage.Value = "confirm" then
// Unconfirmed account; delete the entire thing
do! doDeleteById expired.Id connProps
else
// Some other use; just clear the token
do! saveSecurity { expired with Token = None; TokenUsage = None; TokenExpires = None } connProps
}
/// Check for tokens to purge if it's been more than 10 minutes since we last checked
let private checkForPurge skipCheck =
lock locker (fun () -> backgroundTask {
let now = SystemClock.Instance.GetCurrentInstant ()
if skipCheck || (now - lastPurge).TotalMinutes >= 10 then
do! purgeExpiredTokens now
lastPurge <- now
})
/// Find a citizen by their ID
let findById citizenId = backgroundTask {
match! dataSource () |> getDocument<Citizen> Table.Citizen (CitizenId.toString citizenId) with
| Some c when not c.IsLegacy -> return Some c
| Some _
| None -> return None
}
/// Save a citizen
let save citizen =
saveCitizen citizen (dataSource ())
/// 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 = dataSource ()
use conn = Sql.createConnection connProps
use! txn = conn.BeginTransactionAsync ()
try
do! saveCitizen citizen connProps
do! saveSecurity security connProps
do! txn.CommitAsync ()
return true
with
| :? Npgsql.PostgresException as ex when ex.SqlState = "23505" && ex.ConstraintName = "uk_citizen_email" ->
do! txn.RollbackAsync ()
return false
}
/// Try to find the security information matching a confirmation token
let private tryConfirmToken token connProps = backgroundTask {
let! tryInfo =
connProps
|> Sql.query $"
SELECT *
FROM {Table.SecurityInfo}
WHERE data ->> 'token' = @token
AND data ->> 'tokenUsage' = 'confirm'"
|> Sql.parameters [ "@token", Sql.string token ]
|> Sql.executeAsync toDocument<SecurityInfo>
return List.tryHead tryInfo
}
/// Confirm a citizen's account
let confirmAccount token = backgroundTask {
do! checkForPurge true
let connProps = dataSource ()
match! tryConfirmToken token connProps with
| Some info ->
do! saveSecurity { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None }
connProps
return true
| None -> return false
}
/// Deny a citizen's account (user-initiated; used if someone used their e-mail address without their consent)
let denyAccount token = backgroundTask {
do! checkForPurge true
let connProps = dataSource ()
match! tryConfirmToken token connProps with
| Some info ->
do! doDeleteById info.Id connProps
return true
| None -> return false
}
/// Attempt a user log on
let tryLogOn email password (pwVerify : Citizen -> string -> bool option) (pwHash : Citizen -> string -> string)
now = backgroundTask {
do! checkForPurge false
let connProps = dataSource ()
let! tryCitizen =
connProps
|> Sql.query $"
SELECT *
FROM {Table.Citizen}
WHERE data ->> 'email' = @email
AND data ->> 'isLegacy' = 'false'"
|> Sql.parameters [ "@email", Sql.string email ]
|> Sql.executeAsync toDocument<Citizen>
match List.tryHead tryCitizen with
| Some citizen ->
let citizenId = CitizenId.toString citizen.Id
let! tryInfo = getDocument<SecurityInfo> Table.SecurityInfo citizenId connProps
let! info = backgroundTask {
match tryInfo with
| Some it -> return it
| None ->
let it = { SecurityInfo.empty with Id = citizen.Id }
do! saveSecurity it connProps
return it
}
if info.AccountLocked then return Error "Log on unsuccessful (Account Locked)"
else
match pwVerify citizen password with
| Some rehash ->
let hash = if rehash then pwHash citizen password else citizen.PasswordHash
do! saveSecurity { info with FailedLogOnAttempts = 0 } connProps
do! saveCitizen { citizen with LastSeenOn = now; PasswordHash = hash } connProps
return Ok { citizen with LastSeenOn = now }
| None ->
let locked = info.FailedLogOnAttempts >= 4
do! { info with FailedLogOnAttempts = info.FailedLogOnAttempts + 1; AccountLocked = locked }
|> saveSecurity <| connProps
return Error $"""Log on unsuccessful{if locked then " - Account is now locked" else ""}"""
| None -> return Error "Log on unsuccessful"
}
/// Try to retrieve a citizen and their security information by their e-mail address
let tryByEmailWithSecurity email = backgroundTask {
let toCitizenSecurityPair row = (toDocument<Citizen> row, toDocumentFrom<SecurityInfo> "sec_data" row)
let! results =
dataSource ()
|> Sql.query $"
SELECT c.*, s.data AS sec_data
FROM {Table.Citizen} c
INNER JOIN {Table.SecurityInfo} s ON s.id = c.id
WHERE c.data ->> 'email' = @email"
|> Sql.parameters [ "@email", Sql.string email ]
|> Sql.executeAsync toCitizenSecurityPair
return List.tryHead results
}
/// Save an updated security information document
let saveSecurityInfo security = backgroundTask {
do! saveSecurity security (dataSource ())
}
/// Try to retrieve security information by the given token
let trySecurityByToken token = backgroundTask {
do! checkForPurge false
let! results =
dataSource ()
|> Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'token' = @token"
|> Sql.parameters [ "@token", Sql.string token ]
|> Sql.executeAsync toDocument<SecurityInfo>
return List.tryHead results
}
// ~~~ LEGACY MIGRATION ~~~ //
/// Get all legacy citizens
let legacy () = backgroundTask {
return!
dataSource ()
|> Sql.query $"SELECT * FROM {Table.Citizen} WHERE data ->> 'isLegacy' = 'true' ORDER BY data ->> 'firstName'"
|> Sql.executeAsync toDocument<Citizen>
}
/// Get all current citizens with verified accounts but without a profile
let current () = backgroundTask {
return!
dataSource ()
|> Sql.query $"
SELECT c.*
FROM {Table.Citizen} c
INNER JOIN {Table.SecurityInfo} si ON si.id = c.id
WHERE c.data ->> 'isLegacy' = 'false'
AND si.data ->> 'accountLocked' = 'false'
AND NOT EXISTS (SELECT 1 FROM {Table.Profile} p WHERE p.id = c.id)"
|> Sql.executeAsync toDocument<Citizen>
}
let migrateLegacy currentId legacyId = backgroundTask {
let oldId = CitizenId.toString legacyId
let connProps = dataSource ()
use conn = Sql.createConnection connProps
use! txn = conn.BeginTransactionAsync ()
try
// Add legacy data to current user
let! profiles =
conn
|> Sql.existingConnection
|> Sql.query $"SELECT * FROM {Table.Profile} WHERE id = @oldId"
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|> Sql.executeAsync toDocument<Profile>
match List.tryHead profiles with
| Some profile ->
do! saveDocument
Table.Profile (CitizenId.toString currentId) (Sql.existingConnection conn)
(mkDoc { profile with Id = currentId; IsLegacy = false })
| None -> ()
let! listings =
conn
|> Sql.existingConnection
|> Sql.query $"SELECT * FROM {Table.Listing} WHERE data ->> 'citizenId' = @oldId"
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|> Sql.executeAsync toDocument<Listing>
for listing in listings do
let newListing = { listing with Id = ListingId.create (); CitizenId = currentId; IsLegacy = false }
do! saveDocument
Table.Listing (ListingId.toString newListing.Id) (Sql.existingConnection conn) (mkDoc newListing)
let! successes =
conn
|> Sql.existingConnection
|> Sql.query $"SELECT * FROM {Table.Success} WHERE data ->> 'citizenId' = @oldId"
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|> Sql.executeAsync toDocument<Success>
for success in successes do
let newSuccess = { success with Id = SuccessId.create (); CitizenId = currentId }
do! saveDocument
Table.Success (SuccessId.toString newSuccess.Id) (Sql.existingConnection conn) (mkDoc newSuccess)
// Delete legacy data
let! _ =
conn
|> Sql.existingConnection
|> Sql.query $"
DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @oldId;
DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @oldId;
DELETE FROM {Table.Citizen} WHERE id = @oldId"
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|> Sql.executeNonQueryAsync
do! txn.CommitAsync ()
return Ok ""
with :? Npgsql.PostgresException as ex ->
do! txn.RollbackAsync ()
return Error ex.MessageText
}

View File

@@ -0,0 +1,164 @@
module JobsJobsJobs.Citizens.Domain
open JobsJobsJobs.Domain
/// The data to add or update an other contact
[<CLIMutable; NoComparison; NoEquality>]
type OtherContactForm =
{ /// The type of the contact
ContactType : string
/// The name of the contact
Name : string
/// The value of the contact (URL, e-mail address, phone, etc.)
Value : string
/// Whether this contact is displayed for public employment profiles and job listings
IsPublic : bool
}
/// Support functions for the contact form
module OtherContactForm =
/// Create a contact form from a contact
let fromContact (contact : OtherContact) =
{ ContactType = ContactType.toString contact.ContactType
Name = defaultArg contact.Name ""
Value = contact.Value
IsPublic = contact.IsPublic
}
/// The data available to update an account profile
[<CLIMutable; NoComparison; NoEquality>]
type AccountProfileForm =
{ /// The first name of the citizen
FirstName : string
/// The last name of the citizen
LastName : string
/// The display name for the citizen
DisplayName : string
/// The citizen's new password
NewPassword : string
/// Confirmation of the citizen's new password
NewPasswordConfirm : string
/// The contacts for this profile
Contacts : OtherContactForm array
}
/// Support functions for the account profile form
module AccountProfileForm =
/// Create an account profile form from a citizen
let fromCitizen (citizen : Citizen) =
{ FirstName = citizen.FirstName
LastName = citizen.LastName
DisplayName = defaultArg citizen.DisplayName ""
NewPassword = ""
NewPasswordConfirm = ""
Contacts = citizen.OtherContacts |> List.map OtherContactForm.fromContact |> Array.ofList
}
/// Form for the forgot / reset password page
[<CLIMutable; NoComparison; NoEquality>]
type ForgotPasswordForm =
{ /// The e-mail address for the account wishing to reset their password
Email : string
}
/// Form for the log on page
[<CLIMutable; NoComparison; NoEquality>]
type LogOnForm =
{ /// A message regarding an error encountered during a log on attempt
ErrorMessage : string option
/// The e-mail address for the user attempting to log on
Email : string
/// The password of the user attempting to log on
Password : string
/// The URL where the user should be redirected after logging on
ReturnTo : string option
}
/// Form for the registration page
[<CLIMutable; NoComparison; NoEquality>]
type RegisterForm =
{ /// The user's first name
FirstName : string
/// The user's last name
LastName : string
/// The user's display name
DisplayName : string option
/// The user's e-mail address
Email : string
/// The user's desired password
Password : string
/// The index of the first question asked
Question1Index : int
/// The answer for the first question asked
Question1Answer : string
/// The index of the second question asked
Question2Index : int
/// The answer for the second question asked
Question2Answer : string
}
/// Support for the registration page view model
module RegisterForm =
/// An empty view model
let empty =
{ FirstName = ""
LastName = ""
DisplayName = None
Email = ""
Password = ""
Question1Index = 0
Question1Answer = ""
Question2Index = 0
Question2Answer = ""
}
/// The form for a user resetting their password
[<CLIMutable; NoComparison; NoEquality>]
type ResetPasswordForm =
{ /// The ID of the citizen whose password is being reset
Id : string
/// The verification token for the password reset
Token : string
/// The new password for the account
Password : string
}
// ~~~ LEGACY MIGRATION ~~ //
[<CLIMutable; NoComparison; NoEquality>]
type LegacyMigrationForm =
{ /// The ID of the current citizen
Id : string
/// The ID of the legacy citizen to be migrated
LegacyId : string
}

View File

@@ -0,0 +1,383 @@
module JobsJobsJobs.Citizens.Handlers
open System
open System.Security.Claims
open Giraffe
open JobsJobsJobs
open JobsJobsJobs.Citizens.Domain
open JobsJobsJobs.Common.Handlers
open JobsJobsJobs.Domain
open Microsoft.AspNetCore.Authentication
open Microsoft.AspNetCore.Authentication.Cookies
open Microsoft.Extensions.Logging
open NodaTime
/// Authorization functions
module private Auth =
open System.Text
/// Create a confirmation or password reset token for a user
let createToken (citizen : Citizen) =
Convert.ToBase64String (Guid.NewGuid().ToByteArray () |> Array.append (Encoding.UTF8.GetBytes citizen.Email))
/// The challenge questions and answers from the configuration
let mutable private challenges : (string * string)[] option = None
/// The challenge questions and answers
let questions ctx =
match challenges with
| Some it -> it
| None ->
let qs = (config ctx).GetSection "ChallengeQuestions"
let qAndA =
seq {
for idx in 0..4 do
let section = qs.GetSection(string idx)
yield section["Question"], (section["Answer"].ToLowerInvariant ())
}
|> Array.ofSeq
challenges <- Some qAndA
qAndA
/// Password hashing and verification
module Passwords =
open Microsoft.AspNetCore.Identity
/// The password hasher to use for the application
let private hasher = PasswordHasher<Citizen> ()
/// Hash a password for a user
let hash citizen password =
hasher.HashPassword (citizen, password)
/// Verify a password (returns true if the password needs to be rehashed)
let verify citizen password =
match hasher.VerifyHashedPassword (citizen, citizen.PasswordHash, password) with
| PasswordVerificationResult.Success -> Some false
| PasswordVerificationResult.SuccessRehashNeeded -> Some true
| _ -> None
/// Require an administrative user (used for legacy migration endpoints)
let requireAdmin : HttpHandler = requireUser >=> fun next ctx -> task {
let adminUser = (config ctx)["AdminUser"]
if adminUser = defaultArg (tryUser ctx) "" then return! next ctx
else return! Error.notAuthorized next ctx
}
// GET: /citizen/account
let account : HttpHandler = fun next ctx -> task {
match! Data.findById (currentCitizenId ctx) with
| Some citizen ->
return!
Views.account (AccountProfileForm.fromCitizen citizen) (isHtmx ctx) (csrf ctx)
|> render "Account Profile" next ctx
| None -> return! Error.notFound next ctx
}
// GET: /citizen/cancel-reset/[token]
let cancelReset token : HttpHandler = fun next ctx -> task {
let! wasCanceled = task {
match! Data.trySecurityByToken token with
| Some security ->
do! Data.saveSecurityInfo { security with Token = None; TokenUsage = None; TokenExpires = None }
return true
| None -> return false
}
return! Views.resetCanceled wasCanceled |> render "Password Reset Cancellation" next ctx
}
// GET: /citizen/confirm/[token]
let confirm token : HttpHandler = fun next ctx -> task {
let! isConfirmed = Data.confirmAccount token
return! Views.confirmAccount isConfirmed |> render "Account Confirmation" next ctx
}
// GET: /citizen/dashboard
let dashboard : HttpHandler = requireUser >=> fun next ctx -> task {
let citizenId = currentCitizenId ctx
let! citizen = Data.findById citizenId
let! profile = Profiles.Data.findById citizenId
let! prfCount = Profiles.Data.count ()
return! Views.dashboard citizen.Value profile prfCount (timeZone ctx) |> render "Dashboard" next ctx
}
// POST: /citizen/delete
let delete : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
do! Data.deleteById (currentCitizenId ctx)
do! ctx.SignOutAsync ()
return! render "Account Deleted Successfully" next ctx Views.deleted
}
// GET: /citizen/deny/[token]
let deny token : HttpHandler = fun next ctx -> task {
let! wasDeleted = Data.denyAccount token
return! Views.denyAccount wasDeleted |> render "Account Deletion" next ctx
}
// GET: /citizen/forgot-password
let forgotPassword : HttpHandler = fun next ctx ->
Views.forgotPassword (csrf ctx) |> render "Forgot Password" next ctx
// POST: /citizen/forgot-password
let doForgotPassword : HttpHandler = validateCsrf >=> fun next ctx -> task {
let! form = ctx.BindFormAsync<ForgotPasswordForm> ()
match! Data.tryByEmailWithSecurity form.Email with
| Some (citizen, security) ->
let withToken =
{ security with
Token = Some (Auth.createToken citizen)
TokenUsage = Some "reset"
TokenExpires = Some (now ctx + (Duration.FromDays 3))
}
do! Data.saveSecurityInfo withToken
let! emailResponse = Email.sendPasswordReset citizen withToken
let logFac = logger ctx
let log = logFac.CreateLogger "JobsJobsJobs.Handlers.Citizen"
log.LogInformation $"Password reset e-mail for {citizen.Email} received {emailResponse}"
| None -> ()
return! Views.forgotPasswordSent form |> render "Reset Request Processed" next ctx
}
// GET: /citizen/log-off
let logOff : HttpHandler = requireUser >=> fun next ctx -> task {
do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
do! addSuccess "Log off successful" ctx
return! redirectToGet "/" next ctx
}
// GET: /citizen/log-on
let logOn : HttpHandler = fun next ctx ->
let returnTo =
if ctx.Request.Query.ContainsKey "returnUrl" then Some ctx.Request.Query["returnUrl"].[0] else None
Views.logOn { ErrorMessage = None; Email = ""; Password = ""; ReturnTo = returnTo } (csrf ctx)
|> render "Log On" next ctx
// POST: /citizen/log-on
let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task {
let! form = ctx.BindFormAsync<LogOnForm> ()
match! Data.tryLogOn form.Email form.Password Auth.Passwords.verify Auth.Passwords.hash (now ctx) with
| Ok citizen ->
let claims = seq {
Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.Id)
Claim (ClaimTypes.Name, Citizen.name citizen)
}
let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme)
do! ctx.SignInAsync (identity.AuthenticationType, ClaimsPrincipal identity,
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
do! addSuccess "Log on successful" ctx
return! redirectToGet (defaultArg form.ReturnTo "/citizen/dashboard") next ctx
| Error msg ->
do! addError msg ctx
return! Views.logOn { form with Password = "" } (csrf ctx) |> render "Log On" next ctx
}
// GET: /citizen/register
let register next ctx =
// Get two different indexes for NA-knowledge challenge questions
let q1Index = System.Random.Shared.Next(0, 5)
let mutable q2Index = System.Random.Shared.Next(0, 5)
while q1Index = q2Index do
q2Index <- System.Random.Shared.Next(0, 5)
let qAndA = Auth.questions ctx
Views.register (fst qAndA[q1Index]) (fst qAndA[q2Index])
{ RegisterForm.empty with Question1Index = q1Index; Question2Index = q2Index } (isHtmx ctx) (csrf ctx)
|> render "Register" next ctx
// POST: /citizen/register
#nowarn "3511"
let doRegistration : HttpHandler = validateCsrf >=> fun next ctx -> task {
let! form = ctx.BindFormAsync<RegisterForm> ()
let qAndA = Auth.questions ctx
let mutable badForm = false
let errors = [
if form.FirstName.Length < 1 then "First name is required"
if form.LastName.Length < 1 then "Last name is required"
if form.Email.Length < 1 then "E-mail address is required"
if form.Password.Length < 8 then "Password is too short"
if form.Question1Index < 0 || form.Question1Index > 4
|| form.Question2Index < 0 || form.Question2Index > 4
|| form.Question1Index = form.Question2Index then
badForm <- true
else if (snd qAndA[form.Question1Index]) <> (form.Question1Answer.Trim().ToLowerInvariant ())
|| (snd qAndA[form.Question2Index]) <> (form.Question2Answer.Trim().ToLowerInvariant ()) then
"Question answers are incorrect"
]
let refreshPage () =
Views.register (fst qAndA[form.Question1Index]) (fst qAndA[form.Question2Index]) { form with Password = "" }
(isHtmx ctx) (csrf ctx)
|> renderHandler "Register"
if badForm then
do! addError "The form posted was invalid; please complete it again" ctx
return! register next ctx
else if List.isEmpty errors then
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 = Data.register citizen security
if success then
let! emailResponse = Email.sendAccountConfirmation citizen security
let logFac = logger ctx
let log = logFac.CreateLogger "JobsJobsJobs.Handlers.Citizen"
log.LogInformation $"Confirmation e-mail for {citizen.Email} received {emailResponse}"
return! Views.registered |> render "Registration Successful" next ctx
else
do! addError "There is already an account registered to the e-mail address provided" ctx
return! refreshPage () next ctx
else
do! addErrors errors ctx
return! refreshPage () next ctx
}
// GET: /citizen/reset-password/[token]
let resetPassword token : HttpHandler = fun next ctx -> task {
match! Data.trySecurityByToken token with
| Some security ->
return!
Views.resetPassword { Id = CitizenId.toString security.Id; Token = token; Password = "" } (isHtmx ctx)
(csrf ctx)
|> render "Reset Password" next ctx
| None -> return! Error.notFound next ctx
}
// POST: /citizen/reset-password
let doResetPassword : HttpHandler = validateCsrf >=> fun next ctx -> task {
let! form = ctx.BindFormAsync<ResetPasswordForm> ()
let errors = [
if form.Id = "" then "Request invalid; please return to the link in your e-mail and try again"
if form.Token = "" then "Request invalid; please return to the link in your e-mail and try again"
if form.Password.Length < 8 then "Password too short"
]
if List.isEmpty errors then
match! Data.trySecurityByToken form.Token with
| Some security when security.Id = CitizenId.ofString form.Id ->
match! Data.findById security.Id with
| Some citizen ->
do! Data.saveSecurityInfo { security with Token = None; TokenUsage = None; TokenExpires = None }
do! Data.save { citizen with PasswordHash = Auth.Passwords.hash citizen form.Password }
do! addSuccess "Password reset successfully; you may log on with your new credentials" ctx
return! redirectToGet "/citizen/log-on" next ctx
| None -> return! Error.notFound next ctx
| Some _
| None -> return! Error.notFound next ctx
else
do! addErrors errors ctx
return! Views.resetPassword form (isHtmx ctx) (csrf ctx) |> render "Reset Password" next ctx
}
// POST: /citizen/save-account
let saveAccount : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let! theForm = ctx.BindFormAsync<AccountProfileForm> ()
let form = { theForm with Contacts = theForm.Contacts |> Array.filter (box >> isNull >> not) }
let errors = [
if form.FirstName = "" then "First Name is required"
if form.LastName = "" then "Last Name is required"
if form.NewPassword <> form.NewPassword then "New passwords do not match"
if form.Contacts |> Array.exists (fun c -> c.ContactType = "") then "All Contact Types are required"
if form.Contacts |> Array.exists (fun c -> c.Value = "") then "All Contacts are required"
]
if List.isEmpty errors then
match! Data.findById (currentCitizenId ctx) with
| Some citizen ->
let password =
if form.NewPassword = "" then citizen.PasswordHash
else Auth.Passwords.hash citizen form.NewPassword
do! Data.save
{ citizen with
FirstName = form.FirstName
LastName = form.LastName
DisplayName = noneIfEmpty form.DisplayName
PasswordHash = password
OtherContacts = form.Contacts
|> Array.map (fun c ->
{ OtherContact.Name = noneIfEmpty c.Name
ContactType = ContactType.parse c.ContactType
Value = c.Value
IsPublic = c.IsPublic
})
|> List.ofArray
}
let extraMsg = if form.NewPassword = "" then "" else " and password changed"
do! addSuccess $"Account profile updated{extraMsg} successfully" ctx
return! redirectToGet "/citizen/account" next ctx
| None -> return! Error.notFound next ctx
else
do! addErrors errors ctx
return! Views.account form (isHtmx ctx) (csrf ctx) |> render "Account Profile" next ctx
}
// GET: /citizen/so-long
let soLong : HttpHandler = requireUser >=> fun next ctx ->
Views.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx
// ~~~ LEGACY MIGRATION ~~~ //
// GET: /citizen/legacy
let legacy : HttpHandler = Auth.requireAdmin >=> fun next ctx -> task {
let! currentUsers = Data.current ()
let! legacyUsers = Data.legacy ()
return! Views.legacy currentUsers legacyUsers (csrf ctx) |> render "Migrate Legacy Account" next ctx
}
// POST: /citizen/legacy/migrate
let migrateLegacy : HttpHandler = Auth.requireAdmin >=> validateCsrf >=> fun next ctx -> task {
let! form = ctx.BindFormAsync<LegacyMigrationForm> ()
let currentId = CitizenId.ofString form.Id
let legacyId = CitizenId.ofString form.LegacyId
match! Data.migrateLegacy currentId legacyId with
| Ok _ -> do! addSuccess "Migration successful" ctx
| Error err -> do! addError err ctx
return! redirectToGet "/citizen/legacy" next ctx
}
open Giraffe.EndpointRouting
/// All endpoints for this feature
let endpoints =
subRoute "/citizen" [
GET_HEAD [
route "/account" account
routef "/cancel-reset/%s" cancelReset
routef "/confirm/%s" confirm
route "/dashboard" dashboard
routef "/deny/%s" deny
route "/forgot-password" forgotPassword
route "/log-off" logOff
route "/log-on" logOn
route "/register" register
routef "/reset-password/%s" resetPassword
route "/so-long" soLong
route "/legacy" legacy
]
POST [
route "/delete" delete
route "/forgot-password" doForgotPassword
route "/log-on" doLogOn
route "/register" doRegistration
route "/reset-password" doResetPassword
route "/save-account" saveAccount
route "/legacy/migrate" migrateLegacy
]
]

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Data.fs" />
<Compile Include="Views.fs" />
<Compile Include="Handlers.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
<ProjectReference Include="..\Profiles\JobsJobsJobs.Profiles.fsproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,446 @@
/// Views for URLs beginning with /citizen
module JobsJobsJobs.Citizens.Views
open Giraffe.ViewEngine
open Giraffe.ViewEngine.Htmx
open JobsJobsJobs.Citizens.Domain
open JobsJobsJobs.Common.Views
open JobsJobsJobs.Domain
/// The form to add or edit a means of contact
let contactEdit (contacts : OtherContactForm array) =
let mapToInputs (idx : int) (contact : OtherContactForm) =
div [ _id $"contactRow{idx}"; _class "row pb-3" ] [
div [ _class "col-2 col-md-1" ] [
button [ _type "button"; _class "btn btn-sm btn-outline-danger rounded-pill mt-3"; _title "Delete"
_onclick $"jjj.citizen.removeContact({idx})" ] [ txt " &minus; " ]
]
div [ _class "col-10 col-md-4 col-xl-3" ] [
div [ _class "form-floating" ] [
select [ _id $"contactType{idx}"; _name $"Contacts[{idx}].ContactType"; _class "form-control"
_value contact.ContactType; _placeholder "Type"; _required ] [
let optionFor value label =
let typ = ContactType.toString value
option [ _value typ; if contact.ContactType = typ then _selected ] [ txt label ]
optionFor Website "Website"
optionFor Email "E-mail Address"
optionFor Phone "Phone Number"
]
label [ _class "jjj-required"; _for $"contactType{idx}" ] [ txt "Type" ]
]
]
div [ _class "col-12 col-md-4 col-xl-3" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id $"contactName{idx}"; _name $"Contacts[{idx}].Name"; _class "form-control"
_maxlength "1000"; _value contact.Name; _placeholder "Name" ]
label [ _class "jjj-label"; _for $"contactName{idx}" ] [ txt "Name" ]
]
if idx < 1 then
div [ _class "form-text" ] [ txt "Optional; will link sites and e-mail, qualify phone numbers" ]
]
div [ _class "col-12 col-md-7 offset-md-1 col-xl-4 offset-xl-0" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _id $"contactValue{idx}"; _name $"Contacts[{idx}].Value"
_class "form-control"; _maxlength "1000"; _value contact.Value; _placeholder "Contact"
_required ]
label [ _class "jjj-required"; _for "contactValue{idx}" ] [ txt "Contact" ]
]
if idx < 1 then div [ _class "form-text"] [ txt "The URL, e-mail address, or phone number" ]
]
div [ _class "col-12 col-md-3 offset-md-1 col-xl-1 offset-xl-0" ] [
div [ _class "form-check mt-3" ] [
input [ _type "checkbox"; _id $"contactIsPublic{idx}"; _name $"Contacts[{idx}].IsPublic";
_class "form-check-input"; _value "true"; if contact.IsPublic then _checked ]
label [ _class "form-check-label"; _for $"contactIsPublic{idx}" ] [ txt "Public" ]
]
]
]
template [ _id "newContact" ] [
mapToInputs -1 { ContactType = "Website"; Name = ""; Value = ""; IsPublic = false }
]
:: (contacts |> Array.mapi mapToInputs |> List.ofArray)
/// The account edit page
let account (m : AccountProfileForm) isHtmx csrf =
pageWithTitle "Account Profile" [
p [] [
txt "This information is visible to all fellow logged-on citizens. For publicly-visible employment "
txt "profiles and job listings, the &ldquo;Display Name&rdquo; fields and any public contacts will be "
txt "displayed."
]
form [ _class "row g-3"; _method "POST"; _action "/citizen/save-account" ] [
antiForgery csrf
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text"; _autofocus ] (nameof m.FirstName) m.FirstName "First Name" true
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text" ] (nameof m.LastName) m.LastName "Last Name" true
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text" ] (nameof m.DisplayName) m.DisplayName "Display Name" false
div [ _class "form-text" ] [ em [] [ txt "Optional; overrides first/last for display" ] ]
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "password"; _minlength "8" ] (nameof m.NewPassword) "" "New Password" false
div [ _class "form-text" ] [ txt "Leave blank to keep your current password" ]
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "password"; _minlength "8" ] (nameof m.NewPasswordConfirm) "" "Confirm New Password"
false
div [ _class "form-text" ] [ txt "Leave blank to keep your current password" ]
]
div [ _class "col-12" ] [
hr []
h4 [ _class "pb-2" ] [
txt "Ways to Be Contacted &nbsp; "
button [ _type "button"; _class "btn btn-sm btn-outline-primary rounded-pill"
_onclick "jjj.citizen.addContact()" ] [ txt "Add a Contact Method" ]
]
]
yield! contactEdit m.Contacts
div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ]
]
hr []
p [ _class "text-muted fst-italic" ] [
txt "(If you want to delete your profile, or your entire account, "
a [ _href "/citizen/so-long" ] [ rawText "see your deletion options here" ]; txt ".)"
]
jsOnLoad $"
jjj.citizen.nextIndex = {m.Contacts.Length}
jjj.citizen.validatePasswords('{nameof m.NewPassword}', '{nameof m.NewPasswordConfirm}', false)" isHtmx
]
/// The account confirmation page
let confirmAccount isConfirmed =
pageWithTitle "Account Confirmation" [
p [] [
if isConfirmed then
txt "Your account was confirmed successfully! You may "
a [ _href "/citizen/log-on" ] [ rawText "log on here" ]; txt "."
else
txt "The confirmation token did not match any pending accounts. Confirmation tokens are only valid for "
txt "3 days; if the token expired, you will need to re-register, which "
a [ _href "/citizen/register" ] [ txt "you can do here" ]; txt "."
]
]
/// The citizen's dashboard page
let dashboard (citizen : Citizen) (profile : Profile option) profileCount tz =
article [ _class "container" ] [
h3 [ _class "pb-4" ] [ str $"ITM, {citizen.FirstName}!" ]
div [ _class "row row-cols-1 row-cols-md-2" ] [
div [ _class "col" ] [
div [ _class "card h-100" ] [
h5 [ _class "card-header" ] [ txt "Your Profile" ]
div [ _class "card-body" ] [
match profile with
| Some prfl ->
h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [
str $"Last updated {fullDateTime prfl.LastUpdatedOn tz}"
]
p [ _class "card-text" ] [
txt $"Your profile currently lists {List.length prfl.Skills} skill"
txt (if List.length prfl.Skills <> 1 then "s" else ""); txt "."
if prfl.IsSeekingEmployment then
br []; br []
txt "Your profile indicates that you are seeking employment. Once you find it, "
a [ _href "/success-story/add" ] [ txt "tell your fellow citizens about it!" ]
]
| None ->
p [ _class "card-text" ] [
txt "You do not have an employment profile established; click below (or &ldquo;Edit "
txt "Profile&rdquo; in the menu) to get started!"
]
]
div [ _class "card-footer" ] [
match profile with
| Some _ ->
a [ _href $"/profile/{CitizenId.toString citizen.Id}/view"
_class "btn btn-outline-secondary" ] [ txt "View Profile" ]; txt "&nbsp; &nbsp;"
a [ _href "/profile/edit"; _class "btn btn-outline-secondary" ] [ txt "Edit Profile" ]
| None ->
a [ _href "/profile/edit"; _class "btn btn-primary" ] [ txt "Create Profile" ]
]
]
]
div [ _class "col" ] [
div [ _class "card h-100" ] [
h5 [ _class "card-header" ] [ txt "Other Citizens" ]
div [ _class "card-body" ] [
h6 [ _class "card-subtitle mb-3 text-muted fst-italic" ] [
txt (if profileCount = 0L then "No" else $"{profileCount} Total")
txt " Employment Profile"; txt (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;"
|> txt
]
]
div [ _class "card-footer" ] [
a [ _href "/profile/search"; _class "btn btn-outline-secondary" ] [ txt "Search Profiles" ]
]
]
]
]
emptyP
p [] [
txt "To see how this application works, check out &ldquo;How It Works&rdquo; in the sidebar (last updated "
txt "August 29<sup>th</sup>, 2021)."
]
]
/// The account deletion success page
let deleted =
pageWithTitle "Account Deletion Success" [
emptyP; p [] [ txt "Your account has been successfully deleted." ]
emptyP; p [] [ txt "Thank you for participating, and thank you for your courage. #GitmoNation" ]
]
/// The profile or account deletion page
let deletionOptions csrf =
pageWithTitle "Account Deletion Options" [
h4 [ _class "pb-3" ] [ txt "Option 1 &ndash; Delete Your Profile" ]
p [] [
txt "Utilizing this option will remove your current employment profile and skills. This will preserve any "
txt "job listings you may have posted, or any success stories you may have written, and preserves this "
txt "this application&rsquo;s knowledge of you. This is what you want to use if you want to clear out your "
txt "profile and start again (and remove the current one from others&rsquo; view)."
]
form [ _class "text-center"; _method "POST"; _action "/profile/delete" ] [
antiForgery csrf
button [ _type "submit"; _class "btn btn-danger" ] [ txt "Delete Your Profile" ]
]
hr []
h4 [ _class "pb-3" ] [ txt "Option 2 &ndash; Delete Your Account" ]
p [] [
txt "This option will make it like you never visited this site. It will delete your profile, skills, job "
txt "listings, success stories, and account. This is what you want to use if you want to disappear from "
txt "this application."
]
form [ _class "text-center"; _method "POST"; _action "/citizen/delete" ] [
antiForgery csrf
button [ _type "submit"; _class "btn btn-danger" ] [ txt "Delete Your Entire Account" ]
]
]
/// The account denial page
let denyAccount wasDeleted =
pageWithTitle "Account Deletion" [
p [] [
if wasDeleted then txt "The account was deleted successfully; sorry for the trouble."
else
txt "The confirmation token did not match any pending accounts; if this was an inadvertently created "
txt "account, it has likely already been deleted."
]
]
/// The forgot / reset password page
let forgotPassword csrf =
let m = { Email = "" }
pageWithTitle "Forgot Password" [
p [] [
txt "Enter your e-mail address below; if it matches the e-mail address of an account, we will send a "
txt "password reset link."
]
form [ _class "row g-3 pb-3"; _method "POST"; _action "/citizen/forgot-password" ] [
antiForgery csrf
div [ _class "col-12 col-md-6 offset-md-3" ] [
textBox [ _type "email"; _autofocus ] (nameof m.Email) m.Email "E-mail Address" true
]
div [ _class "col-12" ] [ submitButton "send-lock-outline" "Send Reset Link" ]
]
]
/// The page displayed after a forgotten / reset request has been processed
let forgotPasswordSent (m : ForgotPasswordForm) =
pageWithTitle "Reset Request Processed" [
p [] [
txt $"The reset link request has been processed. If the e-mail address {m.Email} matched an account, "
txt "further instructions were sent to that address."
]
]
/// The log on page
let logOn (m : LogOnForm) csrf =
pageWithTitle "Log On" [
match m.ErrorMessage with
| Some msg ->
p [ _class "pb-3 text-center" ] [
span [ _class "text-danger" ] [ txt msg ]; br []
if msg.IndexOf("ocked") > -1 then
txt "If this is a new account, it must be confirmed before it can be used; otherwise, you need to "
a [ _href "/citizen/forgot-password" ] [ txt "request an unlock code" ]
txt " before you may log on."
]
| None -> ()
form [ _class "row g-3 pb-3"; _hxPost "/citizen/log-on" ] [
antiForgery csrf
match m.ReturnTo with
| Some returnTo -> input [ _type "hidden"; _name (nameof m.ReturnTo); _value returnTo ]
| None -> ()
div [ _class "col-12 col-md-6" ] [
textBox [ _type "email"; _autofocus ] (nameof m.Email) m.Email "E-mail Address" true
]
div [ _class "col-12 col-md-6" ] [
textBox [ _type "password" ] (nameof m.Password) "" "Password" true
]
div [ _class "col-12" ] [ submitButton "login" "Log On" ]
]
p [ _class "text-center" ] [
txt "Need an account? "; a [ _href "/citizen/register" ] [ txt "Register for one!" ]
]
p [ _class "text-center" ] [
txt "Forgot your password? "; a [ _href "/citizen/forgot-password" ] [ txt "Request a reset." ]
]
]
/// The registration page
let register q1 q2 (m : RegisterForm) isHtmx csrf =
pageWithTitle "Register" [
form [ _class "row g-3"; _hxPost "/citizen/register" ] [
antiForgery csrf
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text"; _autofocus ] (nameof m.FirstName) m.FirstName "First Name" true
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text" ] (nameof m.LastName) m.LastName "Last Name" true
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text" ] (nameof m.DisplayName) (defaultArg m.DisplayName "") "Display Name" false
div [ _class "form-text fst-italic" ] [ txt "Optional; overrides first/last for display" ]
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "text" ] (nameof m.Email) m.Email "E-mail Address" true
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "password"; _minlength "8" ] (nameof m.Password) "" "Password" true
]
div [ _class "col-6 col-xl-4" ] [
textBox [ _type "password"; _minlength "8" ] "ConfirmPassword" "" "Confirm Password" true
]
div [ _class "col-12" ] [
hr []
p [ _class "mb-0 text-muted fst-italic" ] [
txt "Before your account request is through, you must answer these questions two&hellip;"
]
]
div [ _class "col-12 col-xl-6" ] [
textBox [ _type "text"; _maxlength "30" ] (nameof m.Question1Answer) m.Question1Answer q1 true
input [ _type "hidden"; _name (nameof m.Question1Index); _value (string m.Question1Index ) ]
]
div [ _class "col-12 col-xl-6" ] [
textBox [ _type "text"; _maxlength "30" ] (nameof m.Question2Answer) m.Question2Answer q2 true
input [ _type "hidden"; _name (nameof m.Question2Index); _value (string m.Question2Index ) ]
]
div [ _class "col-12" ] [ submitButton "content-save-outline" "Save" ]
jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" isHtmx
]
]
/// The confirmation page for user registration
let registered =
pageWithTitle "Registration Successful" [
p [] [
txt "You have been successfully registered with Jobs, Jobs, Jobs. Check your e-mail for a confirmation "
txt "link; it will be valid for the next 72 hours (3 days). Once you confirm your account, you will be "
txt "able to log on using the e-mail address and password you provided."
]
p [] [
txt "If the account is not confirmed within the 72-hour window, it will be deleted, and you will need to "
txt "register again."
]
p [] [
txt "If you encounter issues, feel free to reach out to @danieljsummers on No Agenda Social for assistance."
]
]
/// The confirmation page for canceling a reset request
let resetCanceled wasCanceled =
let pgTitle = if wasCanceled then "Password Reset Request Canceled" else "Reset Request Not Found"
pageWithTitle pgTitle [
p [] [
if wasCanceled then txt "Your password reset request has been canceled."
else txt "There was no active password reset request found; it may have already expired."
]
]
/// The password reset page
let resetPassword (m : ResetPasswordForm) isHtmx csrf =
pageWithTitle "Reset Password" [
p [] [ txt "Enter your new password in the fields below" ]
form [ _class "row g-3"; _method "POST"; _action "/citizen/reset-password" ] [
antiForgery csrf
input [ _type "hidden"; _name (nameof m.Id); _value m.Id ]
input [ _type "hidden"; _name (nameof m.Token); _value m.Token ]
div [ _class "col-12 col-md-6 col-xl-4 offset-xl-2" ] [
textBox [ _type "password"; _minlength "8"; _autofocus ] (nameof m.Password) "" "New Password" true
]
div [ _class "col-12 col-md-6 col-xl-4" ] [
textBox [ _type "password"; _minlength "8" ] "ConfirmPassword" "" "Confirm New Password" true
]
div [ _class "col-12" ] [ submitButton "lock-reset" "Reset Password" ]
jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" isHtmx
]
]
// ~~~ LEGACY MIGRATION ~~~ //
let legacy (current : Citizen list) (legacy : Citizen list) csrf =
form [ _class "container"; _hxPost "/citizen/legacy/migrate" ] [
antiForgery csrf
let canProcess = not (List.isEmpty current)
div [ _class "row" ] [
if canProcess then
div [ _class "col-12 col-lg-6 col-xxl-4" ] [
div [ _class "form-floating" ] [
select [ _id "current"; _name "Id"; _class "form-control" ] [
option [ _value "" ] [ txt "&ndash; Select &ndash;" ]
yield!
current
|> List.sortBy Citizen.name
|> List.map (fun it ->
option [ _value (CitizenId.toString it.Id) ] [
str (Citizen.name it); txt " ("; str it.Email; txt ")"
])
]
label [ _for "current" ] [ txt "Current User" ]
]
]
else p [] [ txt "There are no current accounts to which legacy accounts can be migrated" ]
div [ _class "col-12 col-lg-6 offset-xxl-2"] [
table [ _class "table table-sm table-hover" ] [
thead [] [
tr [] [
th [ _scope "col" ] [ txt "Select" ]
th [ _scope "col" ] [ txt "NAS Profile" ]
]
]
legacy |> List.map (fun it ->
let theId = CitizenId.toString it.Id
tr [] [
td [] [
if canProcess then
input [ _type "radio"; _id $"legacy_{theId}"; _name "LegacyId"; _value theId ]
else txt "&nbsp;"
]
td [] [ label [ _for $"legacy_{theId}" ] [ str it.Email ] ]
])
|> tbody []
]
]
]
submitButton "content-save-outline" "Migrate Account"
]
|> List.singleton
|> pageWithTitle "Migrate Legacy Account"