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 EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Server\JobsJobsJobs.Server.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}" Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Server\JobsJobsJobs.Server.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}"
EndProject 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}" Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "JobsJobsJobs.V3Migration", "JobsJobsJobs\JobsJobsJobs.V3Migration\JobsJobsJobs.V3Migration.fsproj", "{DC3E225D-9720-44E8-86AE-DEE71262C9F0}"
EndProject EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "JobsJobsJobs.Data", "JobsJobsJobs\Data\JobsJobsJobs.Data.fsproj", "{BD2A0986-0B08-4C9F-94D4-EA5EF01EC03F}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{DC3E225D-9720-44E8-86AE-DEE71262C9F0}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -53,7 +53,7 @@ Global
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF} {C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
{8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {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} {DC3E225D-9720-44E8-86AE-DEE71262C9F0} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
{BD2A0986-0B08-4C9F-94D4-EA5EF01EC03F} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@ -115,7 +115,7 @@ export default {
* Confirm an account by verifying a token they received via e-mail * Confirm an account by verifying a token they received via e-mail
* *
* @param token The token to be verified * @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> => { confirmToken: async (token : string) : Promise<boolean | string> => {
const resp = await apiResult<Valid>( const resp = await apiResult<Valid>(
@ -125,6 +125,20 @@ export default {
return resp.valid 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 * Log a citizen on
* *

View File

@ -67,6 +67,12 @@ const routes: Array<RouteRecordRaw> = [
component: () => import(/* webpackChunkName: "logon" */ "../views/citizen/ConfirmRegistration.vue"), component: () => import(/* webpackChunkName: "logon" */ "../views/citizen/ConfirmRegistration.vue"),
meta: { auth: false, title: "Account Confirmation" } 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", path: "/citizen/log-on",
name: "LogOn", name: "LogOn",

View File

@ -8,7 +8,7 @@
<p v-else> <p v-else>
The confirmation token did not match any pending accounts. Confirmation tokens are only valid for 3 days; if 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, 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> </p>
</load-data> </load-data>
</article> </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 /// Constants for tables used by Jobs, Jobs, Jobs
module Table = module Table =
@ -138,10 +138,18 @@ open JobsJobsJobs.Domain
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Citizens = module Citizens =
/// Delete a citizen by their ID open NodaTime
let deleteById citizenId = backgroundTask {
/// 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! _ = let! _ =
connection () connProps
|> Sql.query $" |> Sql.query $"
DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @id; DELETE FROM {Table.Success} WHERE data ->> 'citizenId' = @id;
DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @id; DELETE FROM {Table.Listing} WHERE data ->> 'citizenId' = @id;
@ -151,13 +159,9 @@ module Citizens =
() ()
} }
/// Find a citizen by their ID /// Delete a citizen by their ID
let findById citizenId = backgroundTask { let deleteById citizenId =
match! connection () |> getDocument<Citizen> Table.Citizen (CitizenId.toString citizenId) with doDeleteById citizenId (connection ())
| Some c when not c.IsLegacy -> return Some c
| Some _
| None -> return None
}
/// Save a citizen /// Save a citizen
let private saveCitizen (citizen : Citizen) connProps = let private saveCitizen (citizen : Citizen) connProps =
@ -167,6 +171,38 @@ module Citizens =
let private saveSecurity (security : SecurityInfo) connProps = let private saveSecurity (security : SecurityInfo) connProps =
saveDocument Table.SecurityInfo (CitizenId.toString security.Id) connProps (mkDoc security) 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 /// Save a citizen
let save citizen = let save citizen =
saveCitizen citizen (connection ()) saveCitizen citizen (connection ())
@ -182,20 +218,8 @@ module Citizens =
do! txn.CommitAsync () do! txn.CommitAsync ()
} }
/// Purge expired tokens /// Try to find the security information matching a confirmation token
let private purgeExpiredTokens now = backgroundTask { let private tryConfirmToken token connProps = 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 ()
let! tryInfo = let! tryInfo =
connProps connProps
|> Sql.query $" |> Sql.query $"
@ -205,7 +229,14 @@ module Citizens =
AND data ->> 'tokenUsage' = 'confirm'" AND data ->> 'tokenUsage' = 'confirm'"
|> Sql.parameters [ "@token", Sql.string token ] |> Sql.parameters [ "@token", Sql.string token ]
|> Sql.executeAsync toDocument<SecurityInfo> |> 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 -> | Some info ->
do! saveSecurity { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None } do! saveSecurity { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None }
connProps connProps
@ -213,8 +244,20 @@ module Citizens =
| None -> return false | 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 /// Attempt a user log on
let tryLogOn email (pwCheck : string -> bool) now = backgroundTask { let tryLogOn email (pwCheck : string -> bool) now = backgroundTask {
do! checkForPurge false
let connProps = connection () let connProps = connection ()
let! tryCitizen = let! tryCitizen =
connProps connProps

View File

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

View File

@ -17,7 +17,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\JobsJobsJobs.Data\JobsJobsJobs.Data.fsproj" /> <ProjectReference Include="..\Data\JobsJobsJobs.Data.fsproj" />
</ItemGroup> </ItemGroup>
</Project> </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)) TokenExpires = Some (now + (Duration.FromDays 3))
} }
do! Citizens.register citizen security 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 return! ok next ctx
} }
// PATCH: /api/citizen/confirm // PATCH: /api/citizen/confirm
let confirmToken : HttpHandler = fun next ctx -> task { let confirmToken : HttpHandler = fun next ctx -> task {
let! form = ctx.BindJsonAsync<{| token : string |}> () 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 return! json {| valid = valid |} next ctx
} }
@ -506,7 +516,10 @@ let allEndpoints = [
] ]
PATCH [ route "/confirm" Citizen.confirmToken ] PATCH [ route "/confirm" Citizen.confirmToken ]
POST [ route "/register" Citizen.register ] 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 "/continents" Continent.all ]
GET_HEAD [ route "/instances" Instances.all ] GET_HEAD [ route "/instances" Instances.all ]

View File

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