Public version.

main 1.0.0
Johan Carlberg 2026-01-18 20:35:36 +01:00
commit 88364fc096
46 changed files with 2818 additions and 0 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
.build/
.swiftpm/

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
Packages
.build
xcuserdata
*.xcodeproj
DerivedData/
.DS_Store
db.sqlite
.swiftpm
.env
.env.*
!.env.example

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["swiftlang.swift-vscode", "Vapor.vapor-vscode"]
}

89
Dockerfile Normal file
View File

@ -0,0 +1,89 @@
# ================================
# Build image
# ================================
FROM swift:6.0-noble AS build
# Install OS updates
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get install -y libjemalloc-dev
# Set up a build area
WORKDIR /build
# First just resolve dependencies.
# This creates a cached layer that can be reused
# as long as your Package.swift/Package.resolved
# files do not change.
COPY ./Package.* ./
RUN swift package resolve \
$([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true)
# Copy entire repo into container
COPY . .
# Build the application, with optimizations, with static linking, and using jemalloc
# N.B.: The static version of jemalloc is incompatible with the static Swift runtime.
RUN swift build -c release \
--product PostgresWithUsers \
--static-swift-stdlib \
-Xlinker -ljemalloc
# Switch to the staging area
WORKDIR /staging
# Copy main executable to staging area
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/PostgresWithUsers" ./
# Copy static swift backtracer binary to staging area
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
# Copy resources bundled by SPM to staging area
RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
# Copy any resources from the public directory and views directory if the directories exist
# Ensure that by default, neither the directory nor any of its contents are writable.
RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; } || true
RUN [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; } || true
# ================================
# Run image
# ================================
FROM ubuntu:noble
# Make sure all system packages are up to date, and install only essential packages.
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get -q install -y \
libjemalloc2 \
ca-certificates \
tzdata \
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
# libcurl4 \
# If your app or its dependencies import FoundationXML, also install `libxml2`.
# libxml2 \
&& rm -r /var/lib/apt/lists/*
# Create a vapor user and group with /app as its home directory
RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor
# Switch to the new home directory
WORKDIR /app
# Copy built executable and any staged resources from builder
COPY --from=build --chown=vapor:vapor /staging /app
# Provide configuration needed by the built-in crash reporter and some sensible default behaviors.
ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static
# Ensure all further commands run as the vapor user
USER vapor:vapor
# Let Docker bind to port 8080
EXPOSE 8080
# Start the Vapor service when the image is run, default to listening on 8080 in production environment
ENTRYPOINT ["./PostgresWithUsers"]
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]

312
Package.resolved Normal file
View File

@ -0,0 +1,312 @@
{
"originHash" : "39da4f005434f990c0dece54faba957e7edc27104875be011d48221ad538739d",
"pins" : [
{
"identity" : "async-http-client",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client.git",
"state" : {
"revision" : "60235983163d040f343a489f7e2e77c1918a8bd9",
"version" : "1.26.1"
}
},
{
"identity" : "async-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/async-kit.git",
"state" : {
"revision" : "e048c8ee94967e8d8a1c2ec0e1156d6f7fa34d31",
"version" : "1.20.0"
}
},
{
"identity" : "console-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/console-kit.git",
"state" : {
"revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b",
"version" : "4.15.2"
}
},
{
"identity" : "leaf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/leaf.git",
"state" : {
"revision" : "b70a6108e4917f338f6b8848407bf655aa7e405f",
"version" : "4.5.1"
}
},
{
"identity" : "leaf-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/leaf-kit.git",
"state" : {
"revision" : "cf186d8f2ef33e16fd1dd78df36466c22c2e632f",
"version" : "1.13.1"
}
},
{
"identity" : "multipart-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/multipart-kit.git",
"state" : {
"revision" : "3498e60218e6003894ff95192d756e238c01f44e",
"version" : "4.7.1"
}
},
{
"identity" : "postgres-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/postgres-kit.git",
"state" : {
"revision" : "f4d4b9e8db9a907644d67d6a7ecb5f0314eec1ad",
"version" : "2.14.0"
}
},
{
"identity" : "postgres-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/postgres-nio.git",
"state" : {
"revision" : "ccb25dcc428587224633a79c0ce0430eeac3dc0f",
"version" : "1.26.2"
}
},
{
"identity" : "routing-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/routing-kit.git",
"state" : {
"revision" : "93f7222c8e195cbad39fafb5a0e4cc85a8def7ea",
"version" : "4.9.2"
}
},
{
"identity" : "sql-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/sql-kit.git",
"state" : {
"revision" : "0169d063f6eb9f384a85c5ccc881fe2b32de53f1",
"version" : "3.33.1"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms.git",
"state" : {
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
"version" : "1.2.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "f70225981241859eb4aa1a18a75531d26637c8cc",
"version" : "1.4.0"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b",
"version" : "1.0.4"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-certificates",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-certificates.git",
"state" : {
"revision" : "870f4d5fe5fcfedc13f25d70e103150511746404",
"version" : "1.11.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341",
"version" : "1.2.1"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "176abc28e002a9952470f08745cd26fad9286776",
"version" : "3.13.3"
}
},
{
"identity" : "swift-distributed-tracing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-distributed-tracing.git",
"state" : {
"revision" : "b78796709d243d5438b36e74ce3c5ec2d2ece4d8",
"version" : "1.2.1"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "db6eea3692638a65e2124990155cd220c2915903",
"version" : "1.3.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "a0a57e949a8903563aba4615869310c0ebf14c03",
"version" : "1.4.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
"version" : "1.6.4"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics.git",
"state" : {
"revision" : "4c83e1cdf4ba538ef6e43a9bbd0bcc33a0ca46e3",
"version" : "2.7.0"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "a5fea865badcb1c993c85b0f0e8d05a4bd2270fb",
"version" : "2.85.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "a55c3dd3a81d035af8a20ce5718889c0dcab073d",
"version" : "1.29.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5",
"version" : "1.38.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "385f5bd783ffbfff46b246a7db7be8e4f04c53bd",
"version" : "2.33.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "decfd235996bc163b44e10b8a24997a3d2104b90",
"version" : "1.25.0"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8",
"version" : "1.0.3"
}
},
{
"identity" : "swift-service-context",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-service-context.git",
"state" : {
"revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6",
"version" : "1.2.1"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle.git",
"state" : {
"revision" : "e7187309187695115033536e8fc9b2eb87fd956d",
"version" : "2.8.0"
}
},
{
"identity" : "swift-smtp",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sersoft-gmbh/swift-smtp.git",
"state" : {
"revision" : "a1bafbaaaadd12d69b1813d2fa2f2b411b31ec11",
"version" : "2.14.0"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "890830fff1a577dc83134890c7984020c5f6b43b",
"version" : "1.6.2"
}
},
{
"identity" : "vapor",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/vapor.git",
"state" : {
"revision" : "3636f443474769147828a5863e81a31f6f30e92c",
"version" : "4.115.1"
}
},
{
"identity" : "websocket-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/websocket-kit.git",
"state" : {
"revision" : "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167",
"version" : "2.16.1"
}
}
],
"version" : 3
}

63
Package.swift Normal file
View File

@ -0,0 +1,63 @@
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "ManagableUsers",
platforms: [
.macOS(.v13)
],
products: [
.library(
name: "ManagableUsers",
targets: ["ManagableUsers"]
),
],
dependencies: [
// 💧 A server-side Swift web framework.
.package(url: "https://github.com/vapor/vapor.git", from: "4.115.0"),
// 🗄 An ORM for SQL and NoSQL databases.
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
// 🐘 Fluent driver for Postgres.
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"),
// 🍃 An expressive, performant, and extensible templating language built for Swift.
.package(url: "https://github.com/vapor/leaf.git", from: "4.3.0"),
// 🔵 Non-blocking, event-driven networking for Swift. Used for custom executors
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
// A SwiftNIO based implementation for sending emails using SMTP servers.
.package(url: "https://github.com/sersoft-gmbh/swift-smtp.git", from: "2.0.0"),
],
targets: [
.target(
name: "ManagableUsers",
dependencies: [
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
.product(name: "Leaf", package: "leaf"),
.product(name: "Vapor", package: "vapor"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "SwiftSMTPVapor", package: "swift-smtp")
],
swiftSettings: swiftSettings
),
.executableTarget(
name: "SampleApp",
dependencies: [
.target(name: "ManagableUsers"),
],
swiftSettings: swiftSettings
),
.testTarget(
name: "SampleAppTests",
dependencies: [
.target(name: "SampleApp"),
.product(name: "VaporTesting", package: "vapor"),
],
swiftSettings: swiftSettings
)
]
)
var swiftSettings: [SwiftSetting] { [
.enableUpcomingFeature("ExistentialAny"),
] }

0
Public/.gitkeep Normal file
View File

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="30px" viewBox="0 0 30 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="0" x2="30" y2="0" stroke="white" stroke-width="4" stroke-linecap="round"/>
<line x1="0" y1="10" x2="30" y2="10" stroke="white" stroke-width="4" stroke-linecap="round"/>
<line x1="0" y1="20" x2="30" y2="20" stroke="white" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

395
Public/scripts/manage.js Normal file
View File

@ -0,0 +1,395 @@
async function loadData (block) {
const source = block.dataset.source;
block.classList.remove ("loaded");
try {
const response = await fetch (source);
if (response.ok) {
const items = await response.json();
if (Array.isArray (items)) {
const template = block.querySelector ("template");
const tableBody = block.querySelector ("table tbody");
tableBody.innerHTML = "";
items.forEach (item => {
const clone = template.content.cloneNode (true);
window["populate" + block.dataset.item] (clone, item);
tableBody.appendChild (clone);
tableBody.lastElementChild.dataset.itemid = item.id;
});
} else {
window["populate" + block.dataset.item] (block, items);
}
block.classList.add ("loaded");
} else {
if ((response.status == 401) || (response.status == 403)) {
document.location.href = document.body.dataset.baseurl;
} else {
block.classList.add ("failed");
}
}
} catch (error) {
block.classList.add ("failed");
}
}
function populateUser (row, user) {
row.querySelector (".email").innerText = user.email;
row.querySelector (".fullname").innerText = user.fullName;
row.querySelector (".roles").innerText = user.roles.join (", ");
row.querySelector (".active").innerText = user.isActive ? "✓" : "";
}
async function displayUser (container, dialog, userUrl) {
dialog.querySelectorAll (".error")
.forEach (element => {
element.hidden = true;
});
const response = await fetch (userUrl);
if (response.ok) {
const user = await response.json();
dialog.querySelector ("input[name=userid]").value = user.id;
dialog.querySelector ("input[name=email]").value = user.email;
dialog.querySelector ("input[name=fullname]").value = user.fullName;
dialog.querySelector ("input[name=active]").checked = user.isActive;
dialog.querySelectorAll ("input.role")
.forEach (checkbox => {
checkbox.checked = user.roles.includes (checkbox.value);
});
container.classList.add ("loaded");
} else {
if ((response.status == 401) || (response.status == 403)) {
document.location.href = document.body.dataset.baseurl;
} else {
container.classList.add ("failed");
}
}
}
function emptyUser (dialog) {
dialog.querySelector ("input[name=email]").value = null;
dialog.querySelector ("input[name=fullname]").value = null;
dialog.querySelectorAll ("input[name=role]")
.forEach (checkbox => {
checkbox.checked = false;
});
dialog.querySelectorAll (".error")
.forEach (element => {
element.hidden = true;
});
dialog.querySelector ("button.invite").disabled = false;
}
function selectUser (event) {
const row = event.target.closest ("tbody tr");
if (row && this.contains (row)) {
const dialog = document.querySelector (this.dataset.dialog);
window["display" + this.dataset.item] (this, dialog, this.dataset.source + "/" + row.dataset.itemid);
dialog.showModal();
dialog.querySelector ("input[name=email]").focus();
} else if (event.target.classList.contains ("new")) {
const dialog = document.querySelector (event.target.dataset.dialog);
window["empty" + this.dataset.item] (dialog);
dialog.showModal();
dialog.querySelector ("input[name=email]").focus();
}
}
async function saveUser (event) {
event.preventDefault();
const dialog = event.target.closest ("dialog");
if (event.submitter.classList.contains ("save")) {
const userId = event.target.querySelector ("input[name=userid]").value;
const email = event.target.querySelector ("input[name=email]").value;
const fullname = event.target.querySelector ("input[name=fullname]").value;
const isActive = event.target.querySelector ("input[name=active]").checked;
const roles = Array.from (event.target.querySelectorAll ("input[name=role]:checked")).map (checkbox => { return checkbox.value });
let valid = true;
if (email) {
dialog.querySelector (".error[for=email]").hidden = true;
} else {
dialog.querySelector (".error[for=email]").hidden = false;
valid = false;
}
if (fullname) {
dialog.querySelector (".error[for=fullname]").hidden = true;
} else {
dialog.querySelector (".error[for=fullname]").hidden = false;
valid = false;
}
if (valid) {
event.target.querySelector ("button.save").disabled = true;
try {
const url = dialog.dataset.saveUrl + "/" + userId;
const response = await fetch (url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify ({email: email, fullname: fullname, isActive: isActive, roles: roles})
});
if (response.ok) {
dialog.close();
event.target.querySelector ("button.save").disabled = false;
loadData (document.querySelector (".users.loading"));
return true;
} else {
console.error (url + " returned " + response.status);
dialog.querySelector (".error#failure").hidden = false;
dialog.querySelector ("button.save").disabled = false;
if ((response.status == 401) || (response.status == 403)) {
document.location.href = document.body.dataset.baseurl;
}
return true;
}
} catch (error) {
console.error (error);
dialog.querySelector (".error#failure").hidden = false;
dialog.querySelector ("button.save").disabled = false;
}
} else {
return false;
}
} else {
dialog.close();
}
}
async function inviteUser (event) {
event.preventDefault();
const dialog = event.target.closest ("dialog");
if (event.submitter.classList.contains ("invite")) {
const email = event.target.querySelector ("input[name=email]").value;
const fullname = event.target.querySelector ("input[name=fullname").value;
const roles = Array.from (event.target.querySelectorAll ("input[name=role]:checked")).map (checkbox => { return checkbox.value });
let valid = true;
if (email) {
dialog.querySelector (".error[for=email]").hidden = true;
} else {
dialog.querySelector (".error[for=email]").hidden = false;
valid = false;
}
if (fullname) {
dialog.querySelector (".error[for=fullname]").hidden = true;
} else {
dialog.querySelector (".error[for=fullname]").hidden = false;
valid = false;
}
if (valid) {
event.target.querySelector ("button.invite").disabled = true;
try {
const url = dialog.dataset.inviteUrl;
const response = await fetch (url,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify ({email: email, fullname: fullname, roles: roles})
});
if (response.ok) {
dialog.close();
event.target.querySelector ("button.invite").disabled = false;
loadData (document.querySelector (".users.loading"));
return true;
} else {
console.error (url + " returned " + response.status);
dialog.querySelector (".error#failure").hidden = false;
dialog.querySelector ("button.invite").disabled = false;
if ((response.status == 401) || (response.status == 403)) {
document.location.href = document.body.dataset.baseurl;
}
return true;
}
} catch (error) {
console.error (error);
dialog.querySelector (".error#failure").hidden = false;
dialog.querySelector ("button.invite").disabled = false;
}
} else {
return false;
}
} else {
dialog.close();
}
}
function populatePassword (container, token) {
container.querySelector (".email").innerText = token.email;
container.querySelector ("button.save").disabled = false;
container.querySelector (".invalidPassword").hidden = true;
}
async function savePassword (event) {
event.preventDefault();
if (event.submitter.classList.contains ("save")) {
const password = event.target.querySelector ("input[name=password]").value;
const saveUrl = event.target.dataset.saveUrl;
const loginUrl = event.target.dataset.loginUrl;
if (password && (password.length >= 12)) {
try {
event.target.querySelector ("button.save").disabled = true;
event.target.querySelector (".invalidPassword").hidden = true;
const response = await fetch (saveUrl,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify ({password: password})
});
if (response.ok) {
document.location.href = loginUrl;
return true;
} else {
console.error (saveUrl + " returned " + response.status);
event.target.querySelector (".error#failure").hidden = false;
event.target.querySelector ("button.save").disabled = false;
return false;
}
} catch (error) {
console.error (saveUrl + " returned " + error);
event.target.querySelector (".error#failure").hidden = false;
event.target.querySelector ("button.save").disabled = false;
}
} else {
event.target.querySelector (".invalidPassword").hidden = false;
return false;
}
} else {
dialog.close();
}
}
async function login (event) {
event.preventDefault();
const email = event.target.querySelector ("input[name=email]").value;
const password = event.target.querySelector ("input[name=password]").value;
const loginUrl = event.target.dataset.loginUrl;
if (email && password) {
try {
event.target.querySelector ("button#login").disabled = true;
event.target.querySelector (".error#failure").hidden = true;
event.target.querySelector (".invalidEmail").hidden = true;
event.target.querySelector (".invalidPassword").hidden = true;
const response = await fetch (loginUrl,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify ({email: email, password: password})
});
if (response.ok) {
event.target.querySelector ("button#login").disabled = false;
document.location.href = document.body.dataset.baseurl;
return true;
} else {
console.error (loginUrl + " returned " + response.status);
event.target.querySelector (".error#failure").hidden = false;
event.target.querySelector ("button#login").disabled = false;
return false;
}
} catch (error) {
console.error (loginUrl + " returned " + error);
event.target.querySelector (".error#failure").hidden = false;
event.target.querySelector ("button#login").disabled = false;
}
return true;
} else {
event.target.querySelector (".invalidEmail").hidden = email;
event.target.querySelector (".invalidPassword").hidden = password;
return false;
}
}
function showForgotPassword (event) {
event.preventDefault();
document.querySelector ("form#login").hidden = true;
document.querySelector ("form#forgot").hidden = false;
}
async function submitForgotPassword (event) {
event.preventDefault();
const email = event.target.querySelector ("input[name=email]").value;
const resetUrl = event.target.dataset.resetUrl;
if (email) {
try {
event.target.querySelector ("button#reset-password").disabled = true;
const response = await fetch (resetUrl,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify ({email: email})
});
if (response.ok) {
document.querySelector ("form#forgot").hidden = true;
document.querySelector ("div#reset-info").hidden = false;
return true;
} else {
console.error (saveUrl + " returned " + response.status);
event.target.querySelector (".error#failure").hidden = false;
event.target.querySelector ("button#reset-password").disabled = false;
return false;
}
} catch (error) {
console.error (saveUrl + " returned " + error);
event.target.querySelector (".error#failure").hidden = false;
event.target.querySelector ("button#reset-password").disabled = false;
}
} else {
event.target.querySelector (".invalidEmail").hidden = false;
return false;
}
}
function setupGlobalEnvironment() {
document.querySelectorAll (".loading")
.forEach (function (block) {
loadData (block);
block.addEventListener ("click", window["select" + block.dataset.item]);
});
document.querySelectorAll ("form#login")
.forEach (function (form) {
form.addEventListener ("submit", login);
});
document.querySelectorAll ("button#forgot-password")
.forEach (function (button) {
button.addEventListener ("click", showForgotPassword);
});
document.querySelectorAll ("form#forgot")
.forEach (function (form) {
form.addEventListener ("submit", submitForgotPassword);
});
}
document.addEventListener ("DOMContentLoaded", setupGlobalEnvironment);

184
Public/styles/manage.css Normal file
View File

@ -0,0 +1,184 @@
nav.main {
width: 100%;
background: black;
margin-bottom: 1em;
input#menu-toggle {
display: none;
}
label[for=menu-toggle] {
display: none;
vertical-align: middle;
margin: 0 0.3em;
}
#menu-expanded {
display: none;
}
a {
display: inline-block;
padding: 0.5em;
color: white;
text-decoration: none;
}
a:hover {
background-color: darkgray;
}
}
nav.main a.active {
background-color: lightgray;
display: inline-block !important;
}
.loading {
width: 100%;
}
.loading .progress {
width: 100%;
text-align: center;
font-style: italic;
}
.loading.loaded .progress {
display: none;
}
.loading {
table, form, .failed {
display: none;
}
}
.loading.loaded {
table {
display: table;
}
form {
display: block;
}
}
.loading.failed {
.progress {
display: none;
}
.failed {
display: block;
}
}
.users table th {
text-align: left;
}
.users table {
td, th {
padding: 0.5em 1em;
}
}
.users table tbody tr:hover td {
background: #eeeeee;
cursor: pointer;
}
form th, form td {
vertical-align: top;
}
.spinner {
display: inline-block;
width: 1em;
height: 1em;
border-radius: 50%;
background: radial-gradient(farthest-side,#b7b7b7 94%,#0000) top/3.8px 3.8px no-repeat,
conic-gradient(#0000 30%,#b7b7b7);
-webkit-mask: radial-gradient(farthest-side,#0000 calc(100% - 3.8px),#000 0);
animation: spinner-c7wet2 1.2s infinite linear;
}
@keyframes spinner-c7wet2 {
100% {
transform: rotate(1turn);
}
}
dialog {
border-width: 1px;
border-radius: 8px;
padding: 1em;
width: 80%;
height: 90%;
}
dialog > form > textarea {
display: block;
width: 100%;
}
.error {
color: #7f0000;
}
@media screen and (max-width: 600px) {
nav.main {
> a {
display: none;
}
label[for=menu-toggle] {
display: inline-block;
}
input#menu-toggle:checked ~ div#menu-expanded {
display: block;
position: absolute;
background: black;
z-index: 1;
a {
display: block !important;
padding: 0.5em;
color: white;
text-decoration: none;
}
}
}
.list {
table, thead, tbody, th, td, tr {
display: block;
}
thead {
display: none;
}
tr {
margin-bottom: 15px;
border: 1px solid #ddd;
}
td {
position: relative;
padding-left: 4em !important;
text-align: left;
min-height: 1em;
}
td:before {
content: attr(data-label);
position: absolute;
left: 10px;
width: 45%;
font-weight: bold;
}
}
}

155
README.md Normal file
View File

@ -0,0 +1,155 @@
# ManagableUsers
A package providing basic user management when using the Vapor web framework and a PostgreSQL database.
Provides controllers and middleware for authenticating users. This includes web pages for logging in,
handling of forgotten password by sending tokens via email, and logging out.
Provides controllers for managing users. This includes administration pages and inviting users via email.
*Note: This package uses raw SQL for accessing the database, so there are no `Fluent` model for users,
but the database is connected using `Fluent`, so the it can still be used for other items.*
## Try out
The package contains an executable target `SampleApp` with a minimal implementation demonstrating the
functionality.
You can try this to asses the functionality provided by this package, or you can use it as a template for starting your own project.
### Database
To run `SampleApp`, a database is needed.
If you don't already have PostgreSQL installed, refer to [postgresql.org](https://www.postgresql.org/docs/current/tutorial-install.html)
for options.
There's a SQL script provided for creating the tables needed by `SampleApp`.
```
$ psql --file=Resources/Database/Create.sql
```
For creating a new database named `sampleapp`, create the tables, and a corresponding user do the following:
```
$ psql --dbname=postgres --command="CREATE DATABASE sampleapp"
$ psql --dbname=sampleapp --command="CREATE ROLE sampleapp WITH LOGIN PASSWORD 'sampleapp_password'"
$ psql --dbname=sampleapp --file=Resources/Database/Create.sql
$ psql --dbname=sampleapp --command="GRANT CONNECT ON DATABASE sampleapp TO sampleapp"
$ psql --dbname=sampleapp --command="GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sampleapp"
```
There's also a script for creating an initial user:
```
$ psql --dbname=sampleapp --file=Resources/Database/InitialUser.sql --variable=email=someone@example.com
```
The script will return a password URL for setting the password (assuming that the service runs on https://example.com/, it needs to be changed to the actual host to be usable).
The email address is used for logging in, and can also be used for resetting passwords via email if you have email delivery setup.
### Running Application
You can run the application with `swift run`, or from an IDE such as Xcode.
A number of environment variables are needed for proper function.
- `DATABASE_HOST`: Host for the PostgreSQL server, defaults to `localhost`
- `DATABASE_PORT`: Port for the PostgreSQL server, defaults to `5432`
- `DATABASE_NAME`: Name of the database, defaults to `sampleapp`
- `DATABASE_USERNAME`: Username for accessing the database, defaults to `sampleapp`
- `DATABASE_PASSWORD`: Password for accessing the database, defaults to `sampleapp_password`
- `BASE_URL`: URL for accessing the web site (e.g. `http://localhost:8080`)
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_ENCRYPTION`, `SMTP_TIMEOUT`, `SMTP_USERNAME`, `SMTP_PASSWORD` and `SMTP_USE_ESMTP`: Configuring email delivery, see [the documentation for the SwiftSMTP package](https://sersoft-gmbh.github.io/swift-smtp/2.15.0/documentation/swiftsmtp#Creating-a-Configuration) for details.
- `LOG_LEVEL`: Desired level of logging, defaulting to `info`.
The application will write to the log that it has started and which port it's listening on.
### Using application
Use the URL returned from the `InitialUser.sql` script above in a browser. Make sure to adapt it to the actual setup
(e.g. `http://localhost:8080/auth/password/2lG91bumxxvm9Ky7xLy1Nxz2mJoxjRCS` with defaults and without a reverse proxy).
A form for setting a password should be provided at that URL. After setting a password, that password and the email
address provided to the `InitialUser.sql` script can then be used for logging in.
## Getting Started
Create a new Vapor project, or reuse an existing one.
### Dependencies
Add dependency on `managable-users` by adding the package to `Package.swift` as
```swift
.package(url: "https://git.carlberg.org/public/manageable-users.git", from: "1.0.0"),
```
…and adding to the executable target's dependencies as
```swift
.product(name: "ManagableUsers", package: "managable-users"),
``
Copy the contents of the `Resources/Views` folder from the `managable-users` into the `Resources/Views` folder
in the new project, and also copy the contents of the `Public` folder into the `Public` folder.
In the `configure.swift` file, add the import
```swift
import ManagableUsers
```
…and add
```swift
try await ManagableUsers.configure (app)
```
into the `configure()` function.
In the `routes.swift` file, add the import
```swift
import ManagableUsers
```
…and replace everything in the `routes()` function with
```swift
let sessioned = app.grouped (app.sessions.middleware)
.grouped (BasicUser.sessionAuthenticator())
let loggedIn = sessioned
.grouped (BasicUser.redirectMiddleware (path: "auth/login"))
let api = sessioned
.grouped("api")
AuthenticationController<BasicUser>().routes (sessioned, api: api)
AdminController<BasicUser>().routes (loggedIn, api: api)
struct WelcomeContext: Encodable {
let user: BasicUser?
let section: String
}
loggedIn.get { req async throws in
return try await req.view.render("welcome", WelcomeContext (user: req.auth.get (BasicUser.self), section: "home"))
}
```
## Extending
To add useful functionality, just add more routes to `routes.swift`.
To use a consistent look, use `base.leaf` in your Leaf templates for pages.
To add items to the main menu, do that in `configure.swift` by replacing
```swift
try await ManagableUsers.configure (app)
```
with something like
```swift
try await ManagableUsers.configure(app, mainMenu: [MenuItem (section: "inventory", path: "inventory", name: "Inventory")])
```
For `MenuItem`
- `section` corresponds to the `section` included in the rendering context supplied to Leaf.
- `path` corresponds to the URL path to the start page of that section.
- `name` is the name that will be displayed in the menu.
- `role` is an optional role that is required to access that section.
- If `nil` or omitted, it will be shown to all logged in users.
- Corresponds to the `name` column in the `roles` table.

View File

@ -0,0 +1,26 @@
CREATE EXTENSION citext;
CREATE DOMAIN email AS citext
CHECK ( value ~ '^[a-zA-Z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$' );
CREATE TABLE "users" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"email" email UNIQUE NOT NULL,
"full_name" TEXT NOT NULL,
"password" CHARACTER VARYING (1000),
"active" BOOLEAN NOT NULL
);
CREATE TABLE "roles" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL UNIQUE
);
CREATE TABLE "user_roles" (
"user_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE,
"role_name" TEXT NOT NULL REFERENCES "roles" ("name") ON DELETE NO ACTION
);
CREATE TABLE "user_tokens" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"user_id" UUID NOT NULL,
"token" CHARACTER VARYING (1000) NOT NULL,
"insert_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_tokens_user_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
INSERT INTO "roles" ("name") VALUES ('admin');

View File

@ -0,0 +1,21 @@
WITH "created" AS (
INSERT INTO "users" (
"email",
"full_name",
"active")
VALUES (:'email',
'Initial User',
TRUE)
RETURNING "id"
),
"roles" AS (
INSERT INTO "user_roles" ("user_id", "role_name")
SELECT "id", 'admin'
FROM "created"
)
INSERT INTO "user_tokens" ("user_id", "token")
SELECT "id", (SELECT string_agg (substr (c, (random() * length (c) + 1)::integer, 1), '') AS "token"
FROM (VALUES ('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')) AS x(c),
generate_series (1, 32))
FROM "created"
RETURNING 'https://example.com/auth/password/' || "token" AS "Password URL"

View File

@ -0,0 +1,94 @@
#extend("base"):
#export("title", "User administration")
#export("body"):
<div class="users loading list" data-source="#baseURL/api/admin" data-dialog="dialog#user" data-item="User">
<div class="progress">Loading <span class="spinner"></span></div>
<table>
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th>Roles</th>
<th>Active</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<button class="new" data-dialog="dialog#invite">Invite</button>
<template>
<tr>
<td class="email" data-label="Email"></td>
<td class="fullname" data-label="Name"></td>
<td class="roles" data-label="Roles"></td>
<td class="active" data-label="Active"></td>
</tr>
</template>
</div>
<dialog id="user" data-save-url="#baseURL/api/admin">
<form method="post" onsubmit="saveUser (event);">
<input type="hidden" name="userid"></input>
<table>
<tr>
<td><label for="email">Email</label></td>
<td>
<input type="email" name="email" autocomplete="email"></input>
<p class="error" for="email">Email is required</p>
</td>
</tr>
<tr>
<td><label for="fullname">Name</label></td>
<td>
<input type="text" name="fullname" autocomplete="name"></input>
<p class="error" for="fullname">Name is required</p>
</td>
</tr>
<tr>
<td><input type="checkbox" name="active"></input><label for="active">Active</label></td>
</tr>
<tr>
<th colspan="2">Roles</th>
</tr>
#for(role in roles): <tr>
<td><input type="checkbox" name="role" class="role" value="#(role)"></input><label for="role">#(role)</label></td>
</tr>
#endfor <tr>
<td colspan="2">
<button class="save">Save</button><button class="cancel">Cancel</button>
<p class="error" id="failure">Failed to save, try again later.</p>
</td>
</tr>
</table>
</form>
</dialog>
<dialog id="invite" data-invite-url="#baseURL/api/admin">
<form method="post" onsubmit="inviteUser (event);">
<table>
<tr>
<td><label for="email">Email</label></td>
<td>
<input type="email" name="email" autocomplete="email"></input>
<p class="error" for="email">Email is required</p>
</td>
</tr>
<tr>
<td><label for="fullname">Name</label></td>
<td>
<input type="text" name="fullname" autocomplete="name"></input>
<p class="error" for="fullname">Name is required</p>
</td>
</tr>
#for(role in roles): <tr>
<td><input type="checkbox" name="role" class="role" value="#(role)"></input><label for="role">#(role)</label></td>
</tr>
#endfor <tr>
<td colspan="2">
<button class="invite">Invite</button><button class="cancel">Cancel</button>
<p class="error" id="failure">Invite failed, try again later.</p>
</td>
</tr>
</table>
</form>
</dialog>
#endexport
#endextend

12
Resources/Views/base.leaf Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<title>#import("title")</title>
<link rel="stylesheet" href="#baseURL/styles/manage.css" />
<script src="#baseURL/scripts/manage.js"></script>
<meta name="viewport" content="initial-scale=1.0" />
</head>
<body data-baseurl="#baseURL">
#if(section != "login"): #extend("mainmenu")
#endif#import("body")</body>
</html>

View File

@ -0,0 +1,3 @@
Välkommen till #(host).
För att aktivera ditt konto, gå till #baseURL/auth/password/#(token) och skriv in ett lösenord.

View File

@ -0,0 +1,3 @@
Du har begärt ett nytt lösenord på #(host).
För att ändra ditt lösenord, gå till #baseURL/auth/password/#(token) och skriv in ett lösenord.

View File

@ -0,0 +1,56 @@
#extend("base"):
#export("title"):Login#endexport
#export("body"):
<h1>Login</h1>
<form method="post" id="login" data-login-url="#baseURL/api/auth/login"">
<table style="border: none;">
<tr>
<th>Email:</th>
<td><input type="email" name="email" /></td>
</tr>
<tr class="error invalidEmail" hidden>
<td colspan="2">Enter your email address</td>
</tr>
<tr>
<th>Password:</th>
<td><input type="password" name="password" /></td>
</tr>
<tr class="error invalidPassword" hidden>
<td colspan="2">Enter your password</td>
</tr>
<tr>
<td colspan="2"><button id="login">Log in</button> <button id="forgot-password">Forgot password</button></td>
</tr>
<tr class="error" id="failure" hidden>
<td colspan="2">Login failed,<br>check your email and password</td>
</tr>
</table>
</form>
<form method="post" id="forgot" data-reset-url="#baseURL/api/auth/forgot" hidden>
<table style="border: none;">
<tr>
<td colspan="2">Enter your email address and click <em>Initiate</em> and we'll send and email with a link you can use to set a new password.</td>
</tr>
<tr>
<th>Email:</th>
<td><input type="email" name="email" autocomplete="email" /></td>
</tr>
<tr class="error invalidPassword" hidden>
<td>Enter a password with at least 12 characters</td>
</tr>
<tr>
<td colspan="2"><button id="reset-password">Initate</button></td>
</tr>
<tr class="error" id="failure" hidden>
<td>Failed to initate a password reset, try again later</td>
</tr>
</table>
</form>
<div id="reset-info" hidden>
<p>We've sent an email to <span id="reset-email"></span> with a link you can use to set a new password.</p>
</div>
#endexport
#endextend

View File

@ -0,0 +1,10 @@
<nav class="main">
<input id="menu-toggle" type="checkbox">
<label for="menu-toggle">
<img src="#baseURL/images/hamburger.svg">
</label>
#mainmenuItems(section)
<div id="menu-expanded">
#mainmenuItems(section)
</div>
</nav>

View File

@ -0,0 +1,21 @@
#extend("base"):
#export("title"):Reset password#endexport
#export("body"):
<h1>Reset password</h1>
<div class="password loading" data-source="#baseURL/api/auth/token/#(token)" data-item="Password">
<div class="progress">Loading <span class="spinner"></span></div>
<form onsubmit="savePassword (event);" data-save-url="#(baseUrl)/api/auth/password/#(token)" data-login-url="#(baseUrl)/auth/login">
<p>Enter a new password for <span class="email"></span></p>
<p><input type="password" name="password" autocomplete="new-password"></p>
<p class="error invalidPassword" hidden>Enter a password with at least 12 characters</p>
<p><button class="save">Save</button></p>
<p class="error" id="failure" hidden>Failed to set password, try again later</p>
</form>
<div class="failed">
<p>Invalid link, it has either expired or it has already been used</p>
<p>Request a new from the <a href="#baseURL/auth/login">login page</a>.</p>
</div>
</div>
#endexport
#endextend

View File

@ -0,0 +1,12 @@
#extend("base"):
#export("title", "Welcome")
#export("body"):
Hello #(user.email)!
#if(contains(user.roles, "admin")):
<p><a href="#baseURL()/admin">Administration</a></p>
#endif
<p><a href="#baseURL()/auth/logout">Log out</a></p>
#endexport
#endextend

View File

@ -0,0 +1,12 @@
import Vapor
struct UserAuthenticator<User: ManagedUser>: AsyncBasicAuthenticator where User.SessionID == ExpiringUserId {
func authenticate (basic: BasicAuthorization, for request: Request) async throws {
if let user = try await request.db.withSQLConnection { connection in
return try await User.find (email: basic.username, password: basic.password, on: connection)
} {
request.auth.login (user)
}
}
}

View File

@ -0,0 +1,15 @@
import Vapor
public struct UserSessionAuthenticator<SessionUser: ManagedUser>: AsyncSessionAuthenticator where SessionUser.SessionID == ExpiringUserId {
public typealias User = SessionUser
public func authenticate (sessionID: SessionUser.SessionID, for request: Request) async throws {
if let user = try await request.db.withSQLConnection { connection in
return try await SessionUser.authenticate (sessionID: sessionID, on: connection)
} {
request.logger.info ("Seeing user \(user)")
request.auth.login (user)
request.logger.info ("Saw user \(user)")
}
}
}

View File

@ -0,0 +1,132 @@
import Vapor
import Leaf
import PostgresKit
import SwiftSMTPVapor
public struct AdminController<User: ManagedUser>: Sendable where User.SessionID == ExpiringUserId {
public init() {}
public func routes (_ router: any RoutesBuilder, api apiRouter: any RoutesBuilder) {
let admin = router
.grouped ("admin")
.grouped (RoleMiddleware<User> (role: "admin"))
let api = apiRouter
.grouped ("admin")
.grouped (RoleAPIMiddleware<User> (role: "admin"))
admin.get (use: adminPage (request:))
api.get (use: list (request:))
api.get (":id", use: fetch (request:))
api.put (use: invite (request:))
api.post (":id", use: save(request:))
}
struct AdminContext: Encodable {
let roles: [String]
let section: String
}
@Sendable
private func adminPage (request: Request) async throws -> View {
let roles = try await request.db.withSQLConnection { connection in
return try await UserRole.all (connection: connection)
}
return try await request.view.render ("admin", AdminContext (roles: roles.map (\.name), section: "useradmin"))
}
struct UserItem: Content {
let id: UUID
let email: String
let fullName: String
let isActive: Bool
let roles: [String]
init(user: User) {
self.id = user.id
self.email = user.email
self.fullName = user.fullName
self.isActive = user.isActive
self.roles = user.roles
}
}
@Sendable
private func list (request: Request) async throws -> [UserItem] {
return try await request.db.withSQLConnection { connection in
let users = try await User.all(on: connection)
return users.map { UserItem (user: $0) }
}
}
@Sendable
private func fetch (request: Request) async throws -> UserItem {
guard let idString = request.parameters.get ("id"), let id = UUID (uuidString: idString) else {
throw Abort (.badRequest)
}
return try await request.db.withSQLConnection { connection in
guard let user = try await User.fetch (id, on: connection) else {
throw Abort (.notFound)
}
return UserItem (user: user)
}
}
struct Invitation: Decodable {
let email: String
let fullname: String
let roles: [String]
}
@Sendable
private func invite (request: Request) async throws -> Response {
let invitation = try request.content.decode(Invitation.self)
return try await request.db.withConnection { connection in
try await connection.transaction { connection in
let token = try await UserToken.create (connection: connection).token
try await User.create (email: invitation.email, fullname: invitation.fullname, roles: invitation.roles, token: token, on: connection)
let host = try Environment.baseURL.host() ?? ""
let body = try await request.view.render ("email/invite", ["token": token, "host": host])
.data
let message = Email (sender: Email.Contact (emailAddress: try Environment.emailSender),
recipients: [Email.Contact(emailAddress: invitation.email)],
subject: "Aktivera ditt konto på \(host)",
body: .plain (String (buffer: body)))
try await request.swiftSMTP.mailer.send(message)
return Response (status: .created)
}
}
}
struct Save: Decodable {
let email: String
let fullname: String
let roles: [String]
let isActive: Bool
}
@Sendable
private func save (request: Request) async throws -> Response {
guard let userId = request.parameters.get("id", as: UUID.self) else {
throw Abort (.badRequest)
}
let save = try request.content.decode(Save.self)
return try await request.db.withSQLConnection { connection in
guard (try await User.find (userId, on: connection)) != nil else {
throw Abort (.notFound)
}
guard !save.email.isEmpty && !save.fullname.isEmpty else {
throw Abort (.badRequest)
}
try await User.save (id: userId, email: save.email, fullname: save.fullname, roles: save.roles, isActive: save.isActive, on: connection)
return Response (status: .ok)
}
}
}

View File

@ -0,0 +1,128 @@
import Vapor
import FluentPostgresDriver
import SwiftSMTP
public struct AuthenticationController<User: ManagedUser>: Sendable where User.SessionID == ExpiringUserId {
public init() {}
public func routes (_ router: any RoutesBuilder, api apiRouter: any RoutesBuilder) {
let auth = router.grouped ("auth")
let api = apiRouter
.grouped ("auth")
auth.get ("login", use: loginForm)
api.post ("login", use: login)
auth.get ("password", ":token", use: passwordForm)
auth.grouped (LogoutMiddleware<User>()).get ("logout", use: logout)
api.get ("token", ":token", use: token)
api.post ("forgot", use: forgotPassword)
api.post ("password", ":token", use: savePassword)
}
func loginForm (request: Request) async throws -> View {
return try await request.view.render ("login", ["section": "login"])
}
private struct LoginInput: Decodable {
let email: String
let password: String
}
func login (request: Request) async throws -> some Response {
let loginInput = try request.content.decode (LoginInput.self)
return try await request.db.withSQLConnection { connection in
if let user = try await User.find (email: loginInput.email, password: loginInput.password, on: connection) {
request.auth.login (user)
request.logger.info ("Authenticated user \(user)")
return Response (status: .ok)
} else {
return Response (status: .unauthorized)
}
}
}
func passwordForm (request: Request) async throws -> View {
let token = try request.parameters.require ("token")
return try await request.view.render ("password", ["token": token, "section": "login"])
}
func logout (request: Request) async throws -> some Response {
return request.redirect (to: try Environment.baseURL.absoluteString)
}
func token (request: Request) async throws -> UserToken.Token {
let token = try request.parameters.require ("token")
return try await request.db.withSQLConnection { connection in
guard let token = try await UserToken.fetch (token: token, connection: connection) else {
throw Abort (.notFound)
}
return token
}
}
private struct NewPassword: Decodable {
let password: String
}
func savePassword (request: Request) async throws -> Response {
let token = try request.parameters.require ("token")
let newPassword = try request.content.decode (NewPassword.self)
return try await request.db.withConnection { connection in
try await connection.transaction { connection in
guard let userToken = try await UserToken.fetch (token: token, connection: connection) else {
throw Abort (.notFound)
}
try await User.update (userId: userToken.userId, password: newPassword.password, on: connection)
try await UserToken.delete (token: token, connection: connection)
return Response(status: .ok)
}
}
}
private struct Input: Decodable {
let email: String
}
func forgotPassword (request: Request) async throws -> Response {
let input = try request.content.decode (Input.self)
return try await request.db.withConnection { connection in
try await connection.transaction { connection in
if let user = try await User.find (email: input.email, on: connection) {
let token = try await UserToken.create (connection: connection).token
try await User.store (token: token, userId: user.id, on: connection)
let host = try Environment.baseURL.host() ?? ""
let body = try await request.view.render ("email/reset", ["token": token, "host": host, "section": "login"])
.data
let message = Email (sender: Email.Contact (emailAddress: try Environment.emailSender),
recipients: [Email.Contact(emailAddress: input.email)],
subject: "Aktivera ditt konto på \(host)",
body: .plain (String (buffer: body)))
try await request.swiftSMTP.mailer.send (message)
}
return Response(status: .ok)
}
}
}
}
struct LogoutMiddleware<User: ManagedUser>: AsyncMiddleware {
func respond (to request: Vapor.Request, chainingTo next: any AsyncResponder) async throws -> Vapor.Response {
let response = try await next.respond (to: request)
request.auth.logout (User.self)
request.session.destroy()
return response
}
}

View File

@ -0,0 +1,27 @@
import Vapor
import Fluent
import FluentPostgresDriver
import SwiftSMTPVapor
public struct ManagableUsers {
public static func configure (_ app: Application, mainMenu: [MenuItem] = [], homePageName: String = "Home", userAdminPageName: String = "User Administration") async throws {
app.databases.use(DatabaseConfigurationFactory.postgres(configuration: .init(
hostname: Environment.get("DATABASE_HOST") ?? "localhost",
port: Environment.get("DATABASE_PORT").flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber,
username: Environment.get("DATABASE_USERNAME") ?? "sampleapp",
password: Environment.get("DATABASE_PASSWORD") ?? "sampleapp_password",
database: Environment.get("DATABASE_NAME") ?? "sampleapp",
tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)
_ = try Environment.baseURL
_ = try Environment.emailSender
app.lifecycle.use(SMTPInitializer(configuration: .fromEnvironment()))
app.views.use(.leaf)
app.leaf.tags["baseURL"] = BaseURLTag()
app.leaf.tags["hasRole"] = HasRoleTag()
app.leaf.tags["mainmenuItems"] = MenuItemsTag (mainMenu + [MenuItem (section: "useradmin", path: "admin", name: userAdminPageName, role: "admin")], homePageName: homePageName)
}
}

View File

@ -0,0 +1,35 @@
import Vapor
public struct RoleMiddleware<User: ManagedUser>: AsyncMiddleware {
private let role: String
public init (role: String) {
self.role = role
}
public func respond (to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
guard let user = request.auth.get (User.self),
user.roles.contains (role) else {
return request.redirect(to: try Environment.baseURL.absoluteString)
}
return try await next.respond (to: request)
}
}
public struct RoleAPIMiddleware<User: ManagedUser>: AsyncMiddleware {
private let role: String
public init (role: String) {
self.role = role
}
public func respond (to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
let user = try request.auth.require (User.self)
guard user.roles.contains (role) else {
throw Abort (.forbidden)
}
return try await next.respond (to: request)
}
}

View File

@ -0,0 +1,129 @@
import Vapor
import PostgresKit
import Crypto
// MARK: Defaults for Administration
extension ManagedUser {
public static func all (on connection: any SQLDatabase) async throws -> [Self] {
return try await connection.raw("""
SELECT "id",
"email",
"full_name",
"active",
ARRAY (SELECT "role_name"
FROM "user_roles"
WHERE "user_roles"."user_id" = "users"."id") AS "roles"
FROM "users"
ORDER BY "email"
""")
.all (decoding: Self.self)
}
public static func fetch (_ id: UUID, on connection: any SQLDatabase) async throws -> Self? {
return try await connection.raw("""
SELECT "id",
"email",
"full_name",
"active",
"password",
ARRAY (SELECT "role_name"
FROM "user_roles"
WHERE "user_roles"."user_id" = "users"."id") AS "roles"
FROM "users"
WHERE "id" = \(bind: id)
""")
.first (decoding: Self.self)
}
public static func create (email: String, fullname: String, roles: [String], token: String, on connection: any SQLDatabase) async throws {
if roles.isEmpty {
try await connection.raw("""
WITH created AS (
INSERT INTO "users" (
"email",
"full_name",
"active")
VALUES (\(bind: email),
\(bind: fullname),
TRUE)
RETURNING "id"
)
INSERT INTO "user_tokens" ("user_id", "token")
SELECT "id", \(bind: token)
FROM created
""")
.run()
} else {
try await connection.raw("""
WITH "created" AS (
INSERT INTO "users" (
"email",
"full_name",
"active")
VALUES (\(bind: email),
\(bind: fullname),
TRUE)
RETURNING "id"
),
"roles" AS (
INSERT INTO "user_roles" ("user_id", "role_name")
SELECT "id", "role_name"
FROM "created"
CROSS JOIN unnest (\(bind: roles)) AS "role_name"
)
INSERT INTO "user_tokens" ("user_id", "token")
SELECT "id", \(bind: token)
FROM "created"
""")
.run()
}
}
public static func save (id: UUID, email: String, fullname: String, roles: [String], isActive: Bool, on connection: any SQLDatabase) async throws {
try await connection.raw ("""
WITH "update_user" AS (
UPDATE "users"
SET "email" = \(bind: email),
"full_name" = \(bind: fullname),
"active" = \(bind: isActive)
WHERE "id" = \(bind: id)
),
"desired_roles" AS (
SELECT "role_name"
FROM unnest (\(bind: roles)) "role_name"
),
"current_roles" AS (
SELECT "role_name"
FROM "user_roles"
WHERE "user_id" = \(bind: id)
),
"deleting_roles" AS (
DELETE FROM "user_roles"
WHERE "user_id" = \(bind: id)
AND NOT EXISTS (SELECT *
FROM "desired_roles"
WHERE "user_roles"."role_name" = "desired_roles"."role_name")
)
INSERT INTO "user_roles" ("user_id", "role_name")
SELECT \(bind: id), "name"
FROM "roles"
WHERE EXISTS (SELECT *
FROM "desired_roles"
WHERE "role_name" = "name")
AND NOT EXISTS (SELECT *
FROM "user_roles"
WHERE "user_id" = \(bind: id)
AND "role_name" = "name")
""")
.run()
}
public static func store (token: String, userId: UUID, on connection: any SQLDatabase) async throws {
try await connection.raw("""
INSERT INTO "user_tokens" ("user_id", "token")
VALUES (\(bind: userId), \(bind: token))
""")
.run()
}
}

View File

@ -0,0 +1,21 @@
import Foundation
public struct BasicUser: ManagedUser {
public typealias SessionID = ExpiringUserId
public let id: UUID
public let email: String
public let fullName: String
public let password: String?
public let roles: [String]
public let isActive: Bool
enum CodingKeys: String, CodingKey {
case id
case email
case fullName = "full_name"
case password
case roles
case isActive = "active"
}
}

View File

@ -0,0 +1,36 @@
import Foundation
public struct ExpiringUserId: LosslessStringConvertible, Codable, Sendable {
static let expirationInterval: TimeInterval = 60.0 * 60.0
let id: UUID
let expiration: Date
init (user: any ManagedUser) {
id = user.id
expiration = Date (timeIntervalSinceNow: ExpiringUserId.expirationInterval)
}
public init? (_ description: String) {
if let data = Data (base64Encoded: description) {
do {
self = try JSONDecoder().decode (ExpiringUserId.self, from: data)
if expiration.timeIntervalSinceNow < 0.0 {
return nil
}
} catch {
return nil
}
} else {
return nil
}
}
public var description: String {
do {
return try JSONEncoder().encode (self).base64EncodedString()
} catch {
return error.localizedDescription
}
}
}

View File

@ -0,0 +1,110 @@
import Vapor
import SQLKit
import Crypto
public protocol ManagedUser: Content, SessionAuthenticatable {
var id: UUID { get }
var email: String { get }
var fullName: String { get }
var password: String? { get }
var roles: [String] { get }
var isActive: Bool { get }
// MARK: - Authentication
static func find (_ id: UUID, on connection: any SQLDatabase) async throws -> Self?
static func find (email: String, password: String, on connection: any SQLDatabase) async throws -> Self?
static func find (email: String, on connection: any SQLDatabase) async throws -> Self?
static func update (userId: UUID, password: String, on connection: any SQLDatabase) async throws
// MARK: - Administration
static func all (on connection: any SQLDatabase) async throws -> [Self]
static func create (email: String, fullname: String, roles: [String], token: String, on connection: any SQLDatabase) async throws
static func save (id: UUID, email: String, fullname: String, roles: [String], isActive: Bool, on connection: any SQLDatabase) async throws
static func store (token: String, userId: UUID, on connection: any SQLDatabase) async throws
}
extension ManagedUser where SessionID == ExpiringUserId {
public var sessionID: ExpiringUserId {
return ExpiringUserId (user: self)
}
internal static func encrypt (password: String) throws -> String {
return try Bcrypt.hash (password)
}
public static func authenticate (sessionID: SessionID, on connection: any SQLDatabase) async throws -> Self? {
return try await find (sessionID.id, on: connection)
}
private func verify (password: String) throws -> Bool {
if let userPassword = self.password {
return try Bcrypt.verify (password, created: userPassword)
} else {
return false
}
}
public static func sessionAuthenticator() -> UserSessionAuthenticator<Self> {
return UserSessionAuthenticator()
}
static func basicAuthenticator() -> UserAuthenticator<Self> {
return UserAuthenticator()
}
}
// MARK: Defaults for Authentication
extension ManagedUser where SessionID == ExpiringUserId {
public static func find (_ id: UUID, on connection: any SQLDatabase) async throws -> Self? {
return try await connection.raw("""
SELECT "id",
"email",
"full_name",
"active",
"password",
ARRAY (SELECT "role_name"
FROM "user_roles"
WHERE "user_roles"."user_id" = "users"."id") AS "roles"
FROM "users"
WHERE "id" = \(bind: id)
AND "active"
""")
.first (decoding: Self.self)
}
public static func find (email: String, password: String, on connection: any SQLDatabase) async throws -> Self? {
if let user = try await find (email: email, on: connection),
try user.verify (password: password) {
return user
} else {
return nil
}
}
public static func find (email: String, on connection: any SQLDatabase) async throws -> Self? {
return try await connection.raw ("""
SELECT "id",
"email",
"full_name",
"active",
"password",
ARRAY (SELECT "role_name"
FROM "user_roles"
WHERE "user_roles"."user_id" = "users"."id") AS "roles"
FROM "users"
WHERE "email" = \(bind: email)
AND "active"
""")
.first (decoding: Self.self)
}
public static func update (userId: UUID, password: String, on connection: any SQLDatabase) async throws {
try await connection.raw ("""
UPDATE "users"
SET "password" = \(bind: Self.encrypt (password: password))
WHERE "id" = \(bind: userId)
""")
.run()
}
}

View File

@ -0,0 +1,15 @@
import Foundation
import PostgresKit
struct UserRole: Codable {
let name: String
static func all (connection: any SQLDatabase) async throws -> [UserRole] {
return try await connection.raw ("""
SELECT "name"
FROM "roles"
ORDER BY "name"
""")
.all (decoding: UserRole.self)
}
}

View File

@ -0,0 +1,45 @@
import SQLKit
import Vapor
struct UserToken: Decodable {
let token: String
static func create (connection: any SQLDatabase) async throws -> UserToken {
return try await connection.raw ("""
SELECT string_agg (substr (c, (random() * length (c) + 1)::integer, 1), '') AS "token"
FROM (VALUES ('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789')) AS x(c),
generate_series (1, 32)
""")
.first (decoding: UserToken.self)!
}
struct Token: Content {
let userId: UUID
let email: String
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case email
}
}
static func fetch (token: String, connection: any SQLDatabase) async throws -> Token? {
return try await connection.raw ("""
SELECT "user_id", "email"
FROM "user_tokens"
JOIN "users"
ON "users"."id" = "user_tokens"."user_id"
WHERE "token" = \(bind: token)
AND "insert_time" >= CURRENT_TIMESTAMP - INTERVAL '1 HOUR'
""")
.first (decoding: Token.self)
}
static func delete (token: String, connection: any SQLDatabase) async throws {
return try await connection.raw ("""
DELETE FROM "user_tokens"
WHERE "token" = \(bind: token)
""")
.run()
}
}

View File

@ -0,0 +1,10 @@
import Leaf
import Vapor
final class BaseURLTag: LeafTag {
init() { }
func render (_ context: LeafContext) throws -> LeafData {
return LeafData.string (try Environment.baseURL.absoluteString)
}
}

View File

@ -0,0 +1,38 @@
import Vapor
extension Environment {
enum Error: Swift.Error, LocalizedError {
case missing (String)
var errorDescription: String? {
switch self {
case .missing (let variable):
return "Missing environment variable: \(variable)"
}
}
}
static var baseURL: URL {
get throws {
guard let baseURL = Environment.get ("BASE_URL"), let url = URL (string: baseURL) else {
throw Error.missing ("BASE_URL")
}
return url
}
}
static func baseURL (_ path: String) throws -> URL {
return try baseURL.appendingPathComponent (path)
}
static var emailSender: String {
get throws {
guard let sender = Environment.get ("EMAIL_SENDER") else {
throw Error.missing ("EMAIL_SENDER")
}
return sender
}
}
}

View File

@ -0,0 +1,15 @@
import LeafKit
import Vapor
struct HasRoleTag: LeafTag {
func render (_ context: LeafContext) throws -> LeafData {
try context.requireParameterCount (1)
let roles = context.request?.auth.get (BasicUser.self)?.roles ?? []
if let string = context.parameters[0].string {
return LeafData.bool (roles.contains (string))
} else {
throw LeafError (.unknownError ("Unable to convert role parameter to LeafData string"))
}
}
}

View File

@ -0,0 +1,51 @@
import Leaf
import Vapor
public struct MenuItem {
let section: String
let path: String?
let name: String
let role: String?
public init (section: String, path: String?, name: String, role: String? = nil) {
self.section = section
self.path = path
self.name = name
self.role = role
}
}
final class MenuItemsTag: UnsafeUnescapedLeafTag {
let items: [MenuItem]
init (_ items: [MenuItem], homePageName: String = "Home") {
self.items = [MenuItem (section: "home", path: nil, name: homePageName)] + items
}
func render (_ context: LeafContext) throws -> LeafData {
let baseURL = try Environment.baseURL.absoluteString
let roles = context.request?.auth.get (BasicUser.self)?.roles ?? []
let section = context.parameters.first?.string
var lines = [String]()
for item in items {
if item.role == nil || roles.contains (item.role!) {
if let path = item.path {
lines.append (#" <a href="\#(baseURL)/\#(path)"\#(activeClass (section, item.section))>\#(item.name)</a>"#)
} else {
lines.append (#" <a href="\#(baseURL)"\#(activeClass (section, item.section))>\#(item.name)</a>"#)
}
}
}
return LeafData.string (lines.joined (separator: "\n"))
}
private func activeClass (_ section: String?, _ expected: String) -> String {
if section == expected {
#" class="active""#
} else {
""
}
}
}

View File

@ -0,0 +1,115 @@
import SQLKit
import Logging
import NIOCore
import Vapor
#if DEBUG
public class MockDatabase: @unchecked Sendable {
public let logger: Logger = Logger (label: "MockDatabase")
public let eventLoop: any EventLoop
public var queries = [any SQLExpression]()
public var results = [[any SQLRow]]()
public init (eventLoop: any EventLoop) {
self.eventLoop = eventLoop
}
func withConnection<T> (_ body: (any SQLDatabase) async throws -> T) async rethrows -> T {
return try await body (self)
}
public let dialect: any SQLDialect = Dialect()
struct Dialect: SQLDialect {
let name = "MockDatabase"
let identifierQuote: any SQLExpression = SQLRaw(#"""#)
let supportsAutoIncrement = false
let autoIncrementClause: any SQLExpression = SQLRaw("GENERATED BY DEFAULT AS IDENTITY")
func bindPlaceholder(at position: Int) -> any SQLExpression {
SQLRaw("$\(position)")
}
func literalBoolean(_ value: Bool) -> any SQLKit.SQLExpression {
SQLRaw("\(value)")
}
}
}
enum MockDatabaseErrors: Error {
case empty
}
extension MockDatabase: SQLDatabase {
public func execute (sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> ()) -> EventLoopFuture<Void> {
self.queries.append (query)
guard !results.isEmpty else {
return eventLoop.makeFailedFuture (MockDatabaseErrors.empty)
}
for try row in results.removeFirst() {
onRow (row)
}
return eventLoop.makeSucceededFuture(())
}
}
extension MockDatabase {
public struct Row: SQLRow {
public let values: [String: any Sendable]
public init(values: [String : any Sendable]) {
self.values = values
}
public var allColumns: [String] { values.keys.sorted() }
public func contains (column: String) -> Bool {
values.keys.contains (column)
}
public func decodeNil (column: String) throws -> Bool { !values.keys.contains (column) }
public func decode<D> (column: String, as: D.Type) throws -> D where D : Decodable {
guard let value = values[column] as? D else {
throw DecodingError.dataCorrupted (DecodingError.Context (codingPath: [], debugDescription: "Cannot decode \(D.self) from \(String (describing: self))"))
}
return value
}
}
}
extension Application {
protocol MockDatabaseConnectionKey: StorageKey where Value == MockDatabase {}
public struct MockDatabaseKey: MockDatabaseConnectionKey {
public typealias Value = MockDatabase
}
}
/// A wrapper around the errors that can occur during a transaction.
public struct TransactionError: Error {
/// The file in which the transaction was started
public var file: String
/// The line in which the transaction was started
public var line: Int
/// The error thrown when running the `BEGIN` query
public var beginError: (any Error)?
/// The error thrown in the transaction closure
public var closureError: (any Error)?
/// The error thrown while rolling the transaction back. If the ``closureError`` is set,
/// but the ``rollbackError`` is empty, the rollback was successful. If the ``rollbackError``
/// is set, the rollback failed.
public var rollbackError: (any Error)?
/// The error thrown while commiting the transaction.
public var commitError: (any Error)?
}
#endif

View File

@ -0,0 +1,14 @@
import Fluent
import SQLKit
extension Database {
public func withSQLConnection<T: Sendable>(_ closure: @escaping @Sendable (SQLDatabase) async throws -> T) async throws -> T {
return try await self.withConnection { database in
guard let connection = database as? SQLDatabase else {
throw NoSQLDatabaseError (database: database)
}
return try await closure (connection)
}
}
}

View File

@ -0,0 +1,20 @@
import FluentKit
import SQLKit
extension Database {
public func transaction<T: Sendable>(_ closure: @escaping @Sendable (any SQLDatabase) async throws -> T) async throws -> T {
guard let database = self as? SQLDatabase else {
throw NoSQLDatabaseError (database: self)
}
return try await self.transaction { (_: any Database) in
try await closure (database)
}
}
}
struct NoSQLDatabaseError: Error, CustomDebugStringConvertible {
let database: any Database
var debugDescription: String { "\(database) is not a `SQLDatabase`" }
}

View File

@ -0,0 +1,10 @@
import ManagableUsers
import Vapor
public func configure (_ app: Application) async throws {
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
try await ManagableUsers.configure (app)
try routes(app)
}

View File

@ -0,0 +1,31 @@
import Vapor
import Logging
import NIOCore
import NIOPosix
@main
enum Entrypoint {
static func main() async throws {
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = try await Application.make(env)
// This attempts to install NIO as the Swift Concurrency global executor.
// You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency.
// Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down.
// If enabled, you should be careful about calling async functions before this point as it can cause assertion failures.
let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)])
do {
try await configure(app)
try await app.execute()
} catch {
app.logger.report(error: error)
try? await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
}

View File

@ -0,0 +1,18 @@
import ManagableUsers
import Vapor
func routes(_ app: Application) throws {
let sessioned = app.grouped (app.sessions.middleware)
.grouped (BasicUser.sessionAuthenticator())
let loggedIn = sessioned
.grouped (BasicUser.redirectMiddleware (path: "auth/login"))
let api = sessioned
.grouped("api")
AuthenticationController<BasicUser>().routes (sessioned, api: api)
AdminController<BasicUser>().routes (loggedIn, api: api)
loggedIn.get { req async throws in
try await req.view.render("welcome", ["user": req.auth.get (BasicUser.self)])
}
}

View File

@ -0,0 +1,106 @@
import ManagableUsers
@testable import SampleApp
import VaporTesting
import SQLKit
import Testing
@Suite("App Tests", .serialized)
struct AdminTests {
private func withApp(_ test: (Application) async throws -> ()) async throws {
let app = try await Application.make (.testing)
do {
try await SampleApp.configure (app)
let mockDatabase = MockDatabase (eventLoop: app.eventLoopGroup.next())
app.storage[Application.MockDatabaseKey.self] = mockDatabase
try await test (app)
} catch {
try await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
@Test("Unauthenticated list")
func unauthenticatedList() async throws {
try await withApp { app in
try await app.testing().test(
.GET,
"api/admin",
afterResponse: { res async in
#expect(res.status == .unauthorized)
#expect(res.headers["Location"].isEmpty)
})
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 0)
}
}
@Test("Unauthorized list")
func unauthorizedList() async throws {
try await withApp { app in
var session: String?
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
try await app.testing().test(
.POST,
"api/auth/login",
beforeRequest: { request async in
request.headers.contentType = .urlEncodedForm
request.body = ByteBuffer (string: "email=gamma&password=")
},
afterResponse: { res async in
#expect(res.status == .ok)
#expect(res.headers.setCookie?["vapor-session"] != nil)
session = res.headers.setCookie?["vapor-session"]?.string
})
try await app.testing().test(.GET, "api/admin",
beforeRequest: { request async in
request.headers.cookie = ["vapor-session": HTTPCookies.Value (string: session ?? "")]
},
afterResponse: { res async in
#expect(res.status == .forbidden)
})
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 2)
}
}
@Test("Authorized list")
func authorizedList() async throws {
try await withApp { app in
var session: String?
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": ["admin"]])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": ["admin"]])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "active": true, "roles": ["admin"]])])
try await app.testing().test(
.POST,
"api/auth/login",
beforeRequest: { request async in
request.headers.contentType = .urlEncodedForm
request.body = ByteBuffer (string: "email=gamma&password=")
},
afterResponse: { res async in
#expect(res.status == .ok)
#expect(res.headers.setCookie?["vapor-session"] != nil)
session = res.headers.setCookie?["vapor-session"]?.string
})
try await app.testing().test(.GET, "api/admin",
beforeRequest: { request async in
request.headers.cookie = ["vapor-session": HTTPCookies.Value (string: session ?? "")]
},
afterResponse: { res async in
#expect(res.status == .ok)
#expect(res.headers.setCookie?["vapor-session"]?.string == session)
#expect(res.headers.contentType == .json)
})
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 3)
}
}
}

View File

@ -0,0 +1,207 @@
@testable import SampleApp
import ManagableUsers
import VaporTesting
import SQLKit
import Testing
@Suite("App Tests", .serialized)
struct AuthorizationTests {
private func withApp(_ test: (Application) async throws -> ()) async throws {
let app = try await Application.make (.testing)
do {
try await SampleApp.configure (app)
let mockDatabase = MockDatabase (eventLoop: app.eventLoopGroup.next())
app.storage[Application.MockDatabaseKey.self] = mockDatabase
try await test (app)
} catch {
try await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
@Test("Test login redirect")
func loginRedirect() async throws {
try await withApp { app in
try await app.testing().test(.GET, "", afterResponse: { res async in
#expect(res.status == .seeOther)
#expect(res.headers["Location"] == ["auth/login"])
})
}
}
@Test("Test login form")
func loginForm() async throws {
try await withApp { app in
try await app.testing().test(.GET, "auth/login", afterResponse: { res async in
#expect(res.status == .ok)
})
}
}
@Test("Invalid login")
func invalidLogin() async throws {
try await withApp { app in
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": ["admin"]])])
try await app.testing().test(
.POST,
"api/auth/login",
beforeRequest: { request async in
request.headers.contentType = .json
request.body = ByteBuffer (string: #"{"email": "foo", "password": "bar"}"#)
},
afterResponse: { res async in
#expect(res.status == .unauthorized)
#expect(res.headers["Location"].isEmpty)
})
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 1)
}
}
@Test("Valid login")
func validLogin() async throws {
try await withApp { app in
var session: String?
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
try await app.testing().test(
.POST,
"api/auth/login",
beforeRequest: { request async in
request.headers.contentType = .json
request.body = ByteBuffer (string: #"{"email": "gamma", "password": ""}"#)
},
afterResponse: { res async in
#expect(res.status == .ok)
#expect(res.headers["Location"].isEmpty)
#expect(res.headers.setCookie?["vapor-session"] != nil)
session = res.headers.setCookie?["vapor-session"]?.string
})
try await app.testing().test(.GET, "",
beforeRequest: { request async in
request.headers.cookie = ["vapor-session": HTTPCookies.Value (string: session ?? "")]
},
afterResponse: { res async in
#expect(res.status == .ok)
#expect(res.headers.setCookie?["vapor-session"]?.string == session)
})
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 2)
}
}
@Test("Restricted route")
func restrictedRoute() async throws {
try await withApp { app in
var session: String?
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": Array<String>()])])
try await app.testing().test(
.POST,
"api/auth/login",
beforeRequest: { request async in
request.headers.contentType = .json
request.body = ByteBuffer (string: #"{"email": "gamma", "password": ""}"#)
},
afterResponse: { res async in
#expect(res.status == .ok)
#expect(res.headers.setCookie?["vapor-session"] != nil)
session = res.headers.setCookie?["vapor-session"]?.string
})
try await app.testing().test(.GET, "admin",
beforeRequest: { request async in
request.headers.cookie = ["vapor-session": HTTPCookies.Value (string: session ?? "")]
},
afterResponse: { res async in
#expect(res.status == .seeOther)
#expect(res.headers.setCookie?["vapor-session"]?.string == session)
#expect(res.headers["Location"] == ["http://localhost:8080"])
})
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 2)
}
}
@Test("Restricted authorized route")
func restrictedAuthorizedRoute() async throws {
try await withApp { app in
var session: String?
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": ["admin"]])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "gamma", "full_name": "delta", "password": "$2b$12$4bg4BftSpYAHiQsWjjqj2uZlw.LHbSWUsXA4gBL7njnvONYelCNFC", "active": true, "roles": ["admin"]])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["name": "admin"])])
try await app.testing().test(
.POST,
"api/auth/login",
beforeRequest: { request async in
request.headers.contentType = .json
request.body = ByteBuffer (string: #"{"email": "gamma", "password": ""}"#)
},
afterResponse: { res async in
#expect(res.status == .ok)
#expect(res.headers.setCookie?["vapor-session"] != nil)
session = res.headers.setCookie?["vapor-session"]?.string
})
try await app.testing().test(.GET, "admin",
beforeRequest: { request async in
request.headers.cookie = ["vapor-session": HTTPCookies.Value (string: session ?? "")]
},
afterResponse: { res async in
#expect(res.status == .ok)
#expect(res.headers.setCookie?["vapor-session"]?.string == session)
})
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 3)
}
}
@Test("Forgot password route")
func forgotPasswordRoute() async throws {
try await withApp { app in
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: [:])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["id": UUID(), "email": "a@b", "full_name": "Somebody", "password": "", "active": true, "roles": [""]])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["token": "füffe"])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: [:])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: [:])])
try await app.testing().test(.POST, "api/auth/forgot",
beforeRequest: { request async throws in
try request.content.encode (["email": "gamma"])
},
afterResponse: { res async in
#expect(res.status == .ok)
})
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 5)
}
}
@Test("Forgot password route without account")
func forgotPasswordRouteWithoutAccount() async throws {
try await withApp { app in
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: [:])])
app.storage[Application.MockDatabaseKey.self]?.results.append ([])
app.storage[Application.MockDatabaseKey.self]?.results.append ([MockDatabase.Row (values: ["token": "füffe"])])
try await app.testing().test(.POST, "api/auth/forgot",
beforeRequest: { request async throws in
try request.content.encode (["email": "gamma"])
},
afterResponse: { res async in
#expect(res.status == .ok)
})
#expect(app.storage[Application.MockDatabaseKey.self]?.queries.count == 3)
}
}
}