From 12fdf368e047e0027da7aa177de6b10f81bcf107 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 29 Aug 2022 22:38:04 -0400 Subject: [PATCH] Send registration confirmation e-mail - Add deny option - Move Data project directory --- src/JobsJobsJobs.sln | 14 +-- src/JobsJobsJobs/App/src/api/index.ts | 16 ++- src/JobsJobsJobs/App/src/router/index.ts | 6 ++ .../src/views/citizen/ConfirmRegistration.vue | 2 +- .../src/views/citizen/DenyRegistration.vue | 37 +++++++ .../{JobsJobsJobs.Data => Data}/Data.fs | 97 +++++++++++++------ .../JobsJobsJobs.Data.fsproj | 0 .../{JobsJobsJobs.Data => Data}/Json.fs | 2 +- .../JobsJobsJobs.V3Migration.fsproj | 2 +- src/JobsJobsJobs/Server/Email.fs | 46 +++++++++ src/JobsJobsJobs/Server/Handlers.fs | 19 +++- .../Server/JobsJobsJobs.Server.fsproj | 4 +- 12 files changed, 203 insertions(+), 42 deletions(-) create mode 100644 src/JobsJobsJobs/App/src/views/citizen/DenyRegistration.vue rename src/JobsJobsJobs/{JobsJobsJobs.Data => Data}/Data.fs (90%) rename src/JobsJobsJobs/{JobsJobsJobs.Data => Data}/JobsJobsJobs.Data.fsproj (100%) rename src/JobsJobsJobs/{JobsJobsJobs.Data => Data}/Json.fs (97%) create mode 100644 src/JobsJobsJobs/Server/Email.fs diff --git a/src/JobsJobsJobs.sln b/src/JobsJobsJobs.sln index 91622f2..5f4f45e 100644 --- a/src/JobsJobsJobs.sln +++ b/src/JobsJobsJobs.sln @@ -17,10 +17,10 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "JobsJobsJobs\Doma EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Server\JobsJobsJobs.Server.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "JobsJobsJobs.Data", "JobsJobsJobs\JobsJobsJobs.Data\JobsJobsJobs.Data.fsproj", "{30CC1E7C-A843-4DAC-9058-E7C6ACBCE85D}" -EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "JobsJobsJobs.V3Migration", "JobsJobsJobs\JobsJobsJobs.V3Migration\JobsJobsJobs.V3Migration.fsproj", "{DC3E225D-9720-44E8-86AE-DEE71262C9F0}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "JobsJobsJobs.Data", "JobsJobsJobs\Data\JobsJobsJobs.Data.fsproj", "{BD2A0986-0B08-4C9F-94D4-EA5EF01EC03F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -35,14 +35,14 @@ Global {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.Build.0 = Debug|Any CPU {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.ActiveCfg = Release|Any CPU {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU - {30CC1E7C-A843-4DAC-9058-E7C6ACBCE85D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {30CC1E7C-A843-4DAC-9058-E7C6ACBCE85D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {30CC1E7C-A843-4DAC-9058-E7C6ACBCE85D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {30CC1E7C-A843-4DAC-9058-E7C6ACBCE85D}.Release|Any CPU.Build.0 = Release|Any CPU {DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Debug|Any CPU.Build.0 = Debug|Any CPU {DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Release|Any CPU.ActiveCfg = Release|Any CPU {DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Release|Any CPU.Build.0 = Release|Any CPU + {BD2A0986-0B08-4C9F-94D4-EA5EF01EC03F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD2A0986-0B08-4C9F-94D4-EA5EF01EC03F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD2A0986-0B08-4C9F-94D4-EA5EF01EC03F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD2A0986-0B08-4C9F-94D4-EA5EF01EC03F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -53,7 +53,7 @@ Global GlobalSection(NestedProjects) = preSolution {C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF} {8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF} - {30CC1E7C-A843-4DAC-9058-E7C6ACBCE85D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF} {DC3E225D-9720-44E8-86AE-DEE71262C9F0} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF} + {BD2A0986-0B08-4C9F-94D4-EA5EF01EC03F} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF} EndGlobalSection EndGlobal diff --git a/src/JobsJobsJobs/App/src/api/index.ts b/src/JobsJobsJobs/App/src/api/index.ts index 2f28adb..d37e15a 100644 --- a/src/JobsJobsJobs/App/src/api/index.ts +++ b/src/JobsJobsJobs/App/src/api/index.ts @@ -115,7 +115,7 @@ export default { * Confirm an account by verifying a token they received via e-mail * * @param token The token to be verified - * @return True if the token is value, false if it is not, or an error message if one is encountered + * @return True if the token is valid, false if it is not, or an error message if one is encountered */ confirmToken: async (token : string) : Promise => { const resp = await apiResult( @@ -125,6 +125,20 @@ export default { return resp.valid }, + /** + * Deny an account after verifying the token they received via e-mail + * + * @param token The token to be verified + * @return True if the token is valid, false if it is not, or an error message if one is encountered + */ + denyAccount: async (token : string) : Promise => { + const resp = await apiResult( + await fetch(apiUrl("citizen/deny"), reqInit("DELETE", undefined, { token })), "denying account") + if (typeof resp === "string") return resp + if (typeof resp === "undefined") return false + return resp.valid + }, + /** * Log a citizen on * diff --git a/src/JobsJobsJobs/App/src/router/index.ts b/src/JobsJobsJobs/App/src/router/index.ts index c3fbaa7..195d026 100644 --- a/src/JobsJobsJobs/App/src/router/index.ts +++ b/src/JobsJobsJobs/App/src/router/index.ts @@ -67,6 +67,12 @@ const routes: Array = [ component: () => import(/* webpackChunkName: "logon" */ "../views/citizen/ConfirmRegistration.vue"), meta: { auth: false, title: "Account Confirmation" } }, + { + path: "/citizen/deny/:token", + name: "DenyRegistration", + component: () => import(/* webpackChunkName: "deny" */ "../views/citizen/DenyRegistration.vue"), + meta: { auth: false, title: "Account Deletion" } + }, { path: "/citizen/log-on", name: "LogOn", diff --git a/src/JobsJobsJobs/App/src/views/citizen/ConfirmRegistration.vue b/src/JobsJobsJobs/App/src/views/citizen/ConfirmRegistration.vue index 8d80828..b846957 100644 --- a/src/JobsJobsJobs/App/src/views/citizen/ConfirmRegistration.vue +++ b/src/JobsJobsJobs/App/src/views/citizen/ConfirmRegistration.vue @@ -8,7 +8,7 @@

The confirmation token did not match any pending accounts. Confirmation tokens are only valid for 3 days; if the token expired, you will need to re-register, - which you can do here. + which you can do here.

diff --git a/src/JobsJobsJobs/App/src/views/citizen/DenyRegistration.vue b/src/JobsJobsJobs/App/src/views/citizen/DenyRegistration.vue new file mode 100644 index 0000000..6cc243b --- /dev/null +++ b/src/JobsJobsJobs/App/src/views/citizen/DenyRegistration.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/JobsJobsJobs/JobsJobsJobs.Data/Data.fs b/src/JobsJobsJobs/Data/Data.fs similarity index 90% rename from src/JobsJobsJobs/JobsJobsJobs.Data/Data.fs rename to src/JobsJobsJobs/Data/Data.fs index 6d8569a..d3a459c 100644 --- a/src/JobsJobsJobs/JobsJobsJobs.Data/Data.fs +++ b/src/JobsJobsJobs/Data/Data.fs @@ -1,4 +1,4 @@ -namespace JobsJobsJobs.Data +namespace JobsJobsJobs.Data /// Constants for tables used by Jobs, Jobs, Jobs module Table = @@ -137,11 +137,19 @@ open JobsJobsJobs.Domain /// Citizen data access functions [] module Citizens = - - /// Delete a citizen by their ID - let deleteById citizenId = backgroundTask { + + open NodaTime + + /// 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! _ = - connection () + connProps |> Sql.query $" DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @id; DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @id; @@ -151,13 +159,9 @@ module Citizens = () } - /// Find a citizen by their ID - let findById citizenId = backgroundTask { - match! connection () |> getDocument Table.Citizen (CitizenId.toString citizenId) with - | Some c when not c.IsLegacy -> return Some c - | Some _ - | None -> return None - } + /// Delete a citizen by their ID + let deleteById citizenId = + doDeleteById citizenId (connection ()) /// Save a citizen let private saveCitizen (citizen : Citizen) connProps = @@ -167,6 +171,38 @@ module Citizens = 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 = connection () + let! info = + Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" connProps + |> Sql.executeAsync toDocument + 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! connection () |> getDocument 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 (connection ()) @@ -182,20 +218,8 @@ module Citizens = do! txn.CommitAsync () } - /// Purge expired tokens - let private purgeExpiredTokens now = backgroundTask { - let connProps = connection () - let! info = - Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" connProps - |> Sql.executeAsync toDocument - for expired in info |> List.filter (fun it -> it.TokenExpires.Value < now) do - do! saveSecurity { expired with Token = None; TokenUsage = None; TokenExpires = None } connProps - } - - /// Confirm a citizen's account - let confirmAccount token now = backgroundTask { - do! purgeExpiredTokens now - let connProps = connection () + /// Try to find the security information matching a confirmation token + let private tryConfirmToken token connProps = backgroundTask { let! tryInfo = connProps |> Sql.query $" @@ -205,7 +229,14 @@ module Citizens = AND data ->> 'tokenUsage' = 'confirm'" |> Sql.parameters [ "@token", Sql.string token ] |> Sql.executeAsync toDocument - match List.tryHead tryInfo with + return List.tryHead tryInfo + } + + /// Confirm a citizen's account + let confirmAccount token = backgroundTask { + do! checkForPurge true + let connProps = connection () + match! tryConfirmToken token connProps with | Some info -> do! saveSecurity { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None } connProps @@ -213,8 +244,20 @@ module Citizens = | 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 = connection () + 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 (pwCheck : string -> bool) now = backgroundTask { + do! checkForPurge false let connProps = connection () let! tryCitizen = connProps diff --git a/src/JobsJobsJobs/JobsJobsJobs.Data/JobsJobsJobs.Data.fsproj b/src/JobsJobsJobs/Data/JobsJobsJobs.Data.fsproj similarity index 100% rename from src/JobsJobsJobs/JobsJobsJobs.Data/JobsJobsJobs.Data.fsproj rename to src/JobsJobsJobs/Data/JobsJobsJobs.Data.fsproj diff --git a/src/JobsJobsJobs/JobsJobsJobs.Data/Json.fs b/src/JobsJobsJobs/Data/Json.fs similarity index 97% rename from src/JobsJobsJobs/JobsJobsJobs.Data/Json.fs rename to src/JobsJobsJobs/Data/Json.fs index a7c58a9..2101c5e 100644 --- a/src/JobsJobsJobs/JobsJobsJobs.Data/Json.fs +++ b/src/JobsJobsJobs/Data/Json.fs @@ -1,4 +1,4 @@ -module JobsJobsJobs.Data.Json +module JobsJobsJobs.Data.Json open System.Text.Json open System.Text.Json.Serialization diff --git a/src/JobsJobsJobs/JobsJobsJobs.V3Migration/JobsJobsJobs.V3Migration.fsproj b/src/JobsJobsJobs/JobsJobsJobs.V3Migration/JobsJobsJobs.V3Migration.fsproj index 913550a..95cafd4 100644 --- a/src/JobsJobsJobs/JobsJobsJobs.V3Migration/JobsJobsJobs.V3Migration.fsproj +++ b/src/JobsJobsJobs/JobsJobsJobs.V3Migration/JobsJobsJobs.V3Migration.fsproj @@ -17,7 +17,7 @@ - + diff --git a/src/JobsJobsJobs/Server/Email.fs b/src/JobsJobsJobs/Server/Email.fs new file mode 100644 index 0000000..d664002 --- /dev/null +++ b/src/JobsJobsJobs/Server/Email.fs @@ -0,0 +1,46 @@ +module JobsJobsJobs.Api.Email + +open JobsJobsJobs.Domain +open MailKit.Net.Smtp +open MailKit.Security +open MimeKit + +/// Send an account confirmation e-mail +let sendAccountConfirmation citizen security = backgroundTask { + let name = Citizen.name citizen + let token = security.Token.Value + use client = new SmtpClient () + do! client.ConnectAsync ("localhost", 25, SecureSocketOptions.None) + + use msg = new MimeMessage () + msg.From.Add (MailboxAddress ("Jobs, Jobs, Jobs", "daniel@bitbadger.solutions" (* "summersd@localhost" *) )) + msg.To.Add (MailboxAddress (name, citizen.Email (* "summersd@localhost" *) )) + msg.Subject <- "Account Confirmation Request" + + let text = + [ $"ITM, {name}!" + "" + "This e-mail address was recently used to establish an account on" + "Jobs, Jobs, Jobs (noagendacareers.com). Before this account can be" + "used, it needs to be verified. Please click the link below to do so;" + "it will work for the next 72 hours (3 days)." + "" + $"https://noagendacareers.com/citizen/confirm/{token}" + "" + "If you did not take this action, you can do nothing, and the account" + "will be deleted at the end of that time. If you wish to delete it" + "immediately, use the link below (also valid for 72 hours)." + "" + $"https://noagendacareers.com/citizen/deny/{token}" + "" + "TYFYC!" + "" + "--" + "Jobs, Jobs, Jobs" + "https://noagendacareers.com" + ] |> String.concat "\n" + use msgText = new TextPart (Text = text) + msg.Body <- msgText + + return! client.SendAsync msg +} diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 045b63a..1d11166 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -132,14 +132,24 @@ module Citizen = TokenExpires = Some (now + (Duration.FromDays 3)) } do! Citizens.register citizen security - // TODO: generate auth code and e-mail confirmation + 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 } // PATCH: /api/citizen/confirm let confirmToken : HttpHandler = fun next ctx -> task { let! form = ctx.BindJsonAsync<{| token : string |}> () - let! valid = Citizens.confirmAccount form.token (now ctx) + 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 } @@ -506,7 +516,10 @@ let allEndpoints = [ ] PATCH [ route "/confirm" Citizen.confirmToken ] POST [ route "/register" Citizen.register ] - DELETE [ route "" Citizen.delete ] + DELETE [ + route "" Citizen.delete + route "/deny" Citizen.denyToken + ] ] GET_HEAD [ route "/continents" Continent.all ] GET_HEAD [ route "/instances" Instances.all ] diff --git a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj index 10807ec..2c4a48a 100644 --- a/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj +++ b/src/JobsJobsJobs/Server/JobsJobsJobs.Server.fsproj @@ -9,13 +9,14 @@ + + - @@ -24,6 +25,7 @@ +