Send registration confirmation e-mail

- Add deny option
- Move Data project directory
This commit is contained in:
Daniel J. Summers 2022-08-29 22:38:04 -04:00
parent 3196de4003
commit 12fdf368e0
12 changed files with 203 additions and 42 deletions

View File

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

View File

@ -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<boolean | string> => {
const resp = await apiResult<Valid>(
@ -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<boolean | string> => {
const resp = await apiResult<Valid>(
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
*

View File

@ -67,6 +67,12 @@ const routes: Array<RouteRecordRaw> = [
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",

View File

@ -8,7 +8,7 @@
<p v-else>
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 <router-link to="/citzen/register">you can do here</router-link>.
which <router-link to="/citizen/register">you can do here</router-link>.
</p>
</load-data>
</article>

View File

@ -0,0 +1,37 @@
<template>
<article>
<h3 class="pb-3">Account Deletion</h3>
<load-data :load="denyAccount">
<p v-if="isDeleted">
The account was deleted successfully; sorry for the trouble.
</p>
<p v-else>
The confirmation token did not match any pending accounts; if this was an inadvertently created account, it has
likely already been deleted.
</p>
</load-data>
</article>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { useRoute } from "vue-router"
import api from "@/api"
import LoadData from "@/components/LoadData.vue"
const route = useRoute()
/** Whether the account was deleted */
const isDeleted = ref(false)
/** Deny the account after confirming the token */
const denyAccount = async (errors: string[]) => {
const resp = await api.citizen.denyAccount(route.params.token as string)
if (typeof resp === "string") {
errors.push(resp)
} else {
isDeleted.value = resp
}
}
</script>

View File

@ -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
[<RequireQualifiedAccess>]
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<Citizen> 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<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! connection () |> 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 (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<SecurityInfo>
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<SecurityInfo>
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

View File

@ -1,4 +1,4 @@
module JobsJobsJobs.Data.Json
module JobsJobsJobs.Data.Json
open System.Text.Json
open System.Text.Json.Serialization

View File

@ -17,7 +17,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JobsJobsJobs.Data\JobsJobsJobs.Data.fsproj" />
<ProjectReference Include="..\Data\JobsJobsJobs.Data.fsproj" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

@ -9,13 +9,14 @@
<ItemGroup>
<Compile Include="Auth.fs" />
<Compile Include="Email.fs" />
<Compile Include="Handlers.fs" />
<Compile Include="App.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Data\JobsJobsJobs.Data.fsproj" />
<ProjectReference Include="..\Domain\JobsJobsJobs.Domain.fsproj" />
<ProjectReference Include="..\JobsJobsJobs.Data\JobsJobsJobs.Data.fsproj" />
</ItemGroup>
<ItemGroup>
@ -24,6 +25,7 @@
<ItemGroup>
<PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="MailKit" Version="3.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.8" />
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />