Send registration confirmation e-mail
- Add deny option - Move Data project directory
This commit is contained in:
parent
3196de4003
commit
12fdf368e0
@ -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
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
37
src/JobsJobsJobs/App/src/views/citizen/DenyRegistration.vue
Normal file
37
src/JobsJobsJobs/App/src/views/citizen/DenyRegistration.vue
Normal 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>
|
@ -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
|
@ -1,4 +1,4 @@
|
||||
module JobsJobsJobs.Data.Json
|
||||
module JobsJobsJobs.Data.Json
|
||||
|
||||
open System.Text.Json
|
||||
open System.Text.Json.Serialization
|
@ -17,7 +17,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\JobsJobsJobs.Data\JobsJobsJobs.Data.fsproj" />
|
||||
<ProjectReference Include="..\Data\JobsJobsJobs.Data.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
46
src/JobsJobsJobs/Server/Email.fs
Normal file
46
src/JobsJobsJobs/Server/Email.fs
Normal 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
|
||||
}
|
@ -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 ]
|
||||
|
@ -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" />
|
||||
|
Loading…
Reference in New Issue
Block a user