From 88364fc09655971992054ab2693b927aadf207e2 Mon Sep 17 00:00:00 2001 From: Johan Carlberg Date: Sun, 18 Jan 2026 20:35:36 +0100 Subject: [PATCH] Public version. --- .dockerignore | 2 + .gitignore | 11 + .vscode/extensions.json | 3 + Dockerfile | 89 ++++ Package.resolved | 312 ++++++++++++++ Package.swift | 63 +++ Public/.gitkeep | 0 Public/images/hamburger.svg | 6 + Public/scripts/manage.js | 395 ++++++++++++++++++ Public/styles/manage.css | 184 ++++++++ README.md | 155 +++++++ Resources/Database/Create.sql | 26 ++ Resources/Database/InitialUser.sql | 21 + Resources/Views/admin.leaf | 94 +++++ Resources/Views/base.leaf | 12 + Resources/Views/email/invite.leaf | 3 + Resources/Views/email/reset.leaf | 3 + Resources/Views/login.leaf | 56 +++ Resources/Views/mainmenu.leaf | 10 + Resources/Views/password.leaf | 21 + Resources/Views/welcome.leaf | 12 + .../Authentication/UserAuthenticator.swift | 12 + .../UserSessionAuthenticator.swift | 15 + Sources/ManagableUsers/Controllers/.gitkeep | 0 .../Controllers/AdminController.swift | 132 ++++++ .../AuthenticationController.swift | 128 ++++++ Sources/ManagableUsers/ManagableUsers.swift | 27 ++ .../Middleware/RoleMiddleware.swift | 35 ++ .../Models/AdministeredUser.swift | 129 ++++++ Sources/ManagableUsers/Models/BasicUser.swift | 21 + .../Models/ExpiringUserId.swift | 36 ++ .../ManagableUsers/Models/ManagedUser.swift | 110 +++++ Sources/ManagableUsers/Models/UserRole.swift | 15 + Sources/ManagableUsers/Models/UserToken.swift | 45 ++ .../ManagableUsers/Utilities/BaseURLTag.swift | 10 + .../Utilities/Environment+Configuration.swift | 38 ++ .../ManagableUsers/Utilities/HasRoleTag.swift | 15 + .../Utilities/MenuItemsTag.swift | 51 +++ .../Utilities/MockDatabase.swift | 115 +++++ .../Utilities/Request+SQLDatabase.swift | 14 + .../Utilities/SQLDatabase+Transaction.swift | 20 + Sources/SampleApp/configure.swift | 10 + Sources/SampleApp/entrypoint.swift | 31 ++ Sources/SampleApp/routes.swift | 18 + Tests/SampleAppTests/AdminTests.swift | 106 +++++ Tests/SampleAppTests/AuthorizationTests.swift | 207 +++++++++ 46 files changed, 2818 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 Dockerfile create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 Public/.gitkeep create mode 100644 Public/images/hamburger.svg create mode 100644 Public/scripts/manage.js create mode 100644 Public/styles/manage.css create mode 100644 README.md create mode 100644 Resources/Database/Create.sql create mode 100644 Resources/Database/InitialUser.sql create mode 100644 Resources/Views/admin.leaf create mode 100644 Resources/Views/base.leaf create mode 100644 Resources/Views/email/invite.leaf create mode 100644 Resources/Views/email/reset.leaf create mode 100644 Resources/Views/login.leaf create mode 100644 Resources/Views/mainmenu.leaf create mode 100644 Resources/Views/password.leaf create mode 100644 Resources/Views/welcome.leaf create mode 100644 Sources/ManagableUsers/Authentication/UserAuthenticator.swift create mode 100644 Sources/ManagableUsers/Authentication/UserSessionAuthenticator.swift create mode 100644 Sources/ManagableUsers/Controllers/.gitkeep create mode 100644 Sources/ManagableUsers/Controllers/AdminController.swift create mode 100644 Sources/ManagableUsers/Controllers/AuthenticationController.swift create mode 100644 Sources/ManagableUsers/ManagableUsers.swift create mode 100644 Sources/ManagableUsers/Middleware/RoleMiddleware.swift create mode 100644 Sources/ManagableUsers/Models/AdministeredUser.swift create mode 100644 Sources/ManagableUsers/Models/BasicUser.swift create mode 100644 Sources/ManagableUsers/Models/ExpiringUserId.swift create mode 100644 Sources/ManagableUsers/Models/ManagedUser.swift create mode 100644 Sources/ManagableUsers/Models/UserRole.swift create mode 100644 Sources/ManagableUsers/Models/UserToken.swift create mode 100644 Sources/ManagableUsers/Utilities/BaseURLTag.swift create mode 100644 Sources/ManagableUsers/Utilities/Environment+Configuration.swift create mode 100644 Sources/ManagableUsers/Utilities/HasRoleTag.swift create mode 100644 Sources/ManagableUsers/Utilities/MenuItemsTag.swift create mode 100644 Sources/ManagableUsers/Utilities/MockDatabase.swift create mode 100644 Sources/ManagableUsers/Utilities/Request+SQLDatabase.swift create mode 100644 Sources/ManagableUsers/Utilities/SQLDatabase+Transaction.swift create mode 100644 Sources/SampleApp/configure.swift create mode 100644 Sources/SampleApp/entrypoint.swift create mode 100644 Sources/SampleApp/routes.swift create mode 100644 Tests/SampleAppTests/AdminTests.swift create mode 100644 Tests/SampleAppTests/AuthorizationTests.swift diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d9f16e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90a6d3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +Packages +.build +xcuserdata +*.xcodeproj +DerivedData/ +.DS_Store +db.sqlite +.swiftpm +.env +.env.* +!.env.example \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..42783d7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["swiftlang.swift-vscode", "Vapor.vapor-vscode"] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8e48bf --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..82137c4 --- /dev/null +++ b/Package.resolved @@ -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 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..f56d89c --- /dev/null +++ b/Package.swift @@ -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"), +] } diff --git a/Public/.gitkeep b/Public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Public/images/hamburger.svg b/Public/images/hamburger.svg new file mode 100644 index 0000000..7ae4da6 --- /dev/null +++ b/Public/images/hamburger.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Public/scripts/manage.js b/Public/scripts/manage.js new file mode 100644 index 0000000..b49eb33 --- /dev/null +++ b/Public/scripts/manage.js @@ -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); diff --git a/Public/styles/manage.css b/Public/styles/manage.css new file mode 100644 index 0000000..8dbc37c --- /dev/null +++ b/Public/styles/manage.css @@ -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; + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6e2c8b --- /dev/null +++ b/README.md @@ -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().routes (sessioned, api: api) + AdminController().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. diff --git a/Resources/Database/Create.sql b/Resources/Database/Create.sql new file mode 100644 index 0000000..6a62445 --- /dev/null +++ b/Resources/Database/Create.sql @@ -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'); diff --git a/Resources/Database/InitialUser.sql b/Resources/Database/InitialUser.sql new file mode 100644 index 0000000..8cd86b8 --- /dev/null +++ b/Resources/Database/InitialUser.sql @@ -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" diff --git a/Resources/Views/admin.leaf b/Resources/Views/admin.leaf new file mode 100644 index 0000000..72dcc40 --- /dev/null +++ b/Resources/Views/admin.leaf @@ -0,0 +1,94 @@ +#extend("base"): + #export("title", "User administration") + #export("body"): +
+
Loading
+ + + + + + + + + + + +
EmailNameRolesActive
+ + +
+ +
+ + + + + + + + + + + + + + + + +#for(role in roles): + + +#endfor + + +
+ +

Email is required

+
+ +

Name is required

+
Roles
+ +

Failed to save, try again later.

+
+
+
+ +
+ + + + + + + + + +#for(role in roles): + + +#endfor + + +
+ +

Email is required

+
+ +

Name is required

+
+ +

Invite failed, try again later.

+
+
+
+ #endexport +#endextend diff --git a/Resources/Views/base.leaf b/Resources/Views/base.leaf new file mode 100644 index 0000000..0cd17c4 --- /dev/null +++ b/Resources/Views/base.leaf @@ -0,0 +1,12 @@ + + + + #import("title") + + + + + +#if(section != "login"): #extend("mainmenu") +#endif#import("body") + diff --git a/Resources/Views/email/invite.leaf b/Resources/Views/email/invite.leaf new file mode 100644 index 0000000..90a14d2 --- /dev/null +++ b/Resources/Views/email/invite.leaf @@ -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. diff --git a/Resources/Views/email/reset.leaf b/Resources/Views/email/reset.leaf new file mode 100644 index 0000000..1286e7d --- /dev/null +++ b/Resources/Views/email/reset.leaf @@ -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. diff --git a/Resources/Views/login.leaf b/Resources/Views/login.leaf new file mode 100644 index 0000000..024b636 --- /dev/null +++ b/Resources/Views/login.leaf @@ -0,0 +1,56 @@ +#extend("base"): + #export("title"):Login#endexport + #export("body"): +

Login

+ +
+ + + + + + + + + + + + + + + + + + + + + +
Email:
Password:
+
+ + + + + #endexport +#endextend diff --git a/Resources/Views/mainmenu.leaf b/Resources/Views/mainmenu.leaf new file mode 100644 index 0000000..9500b33 --- /dev/null +++ b/Resources/Views/mainmenu.leaf @@ -0,0 +1,10 @@ + diff --git a/Resources/Views/password.leaf b/Resources/Views/password.leaf new file mode 100644 index 0000000..61fe29b --- /dev/null +++ b/Resources/Views/password.leaf @@ -0,0 +1,21 @@ +#extend("base"): + #export("title"):Reset password#endexport + #export("body"): +

Reset password

+ +
+
Loading
+
+

Enter a new password for

+

+ +

+ +
+
+

Invalid link, it has either expired or it has already been used

+

Request a new from the login page.

+
+
+ #endexport +#endextend diff --git a/Resources/Views/welcome.leaf b/Resources/Views/welcome.leaf new file mode 100644 index 0000000..4c40900 --- /dev/null +++ b/Resources/Views/welcome.leaf @@ -0,0 +1,12 @@ +#extend("base"): + #export("title", "Welcome") + #export("body"): + Hello #(user.email)! + + #if(contains(user.roles, "admin")): +

Administration

+ #endif + +

Log out

+ #endexport +#endextend diff --git a/Sources/ManagableUsers/Authentication/UserAuthenticator.swift b/Sources/ManagableUsers/Authentication/UserAuthenticator.swift new file mode 100644 index 0000000..124cb7c --- /dev/null +++ b/Sources/ManagableUsers/Authentication/UserAuthenticator.swift @@ -0,0 +1,12 @@ +import Vapor + +struct UserAuthenticator: 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) + } + } +} diff --git a/Sources/ManagableUsers/Authentication/UserSessionAuthenticator.swift b/Sources/ManagableUsers/Authentication/UserSessionAuthenticator.swift new file mode 100644 index 0000000..3b17359 --- /dev/null +++ b/Sources/ManagableUsers/Authentication/UserSessionAuthenticator.swift @@ -0,0 +1,15 @@ +import Vapor + +public struct UserSessionAuthenticator: 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)") + } + } +} diff --git a/Sources/ManagableUsers/Controllers/.gitkeep b/Sources/ManagableUsers/Controllers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Sources/ManagableUsers/Controllers/AdminController.swift b/Sources/ManagableUsers/Controllers/AdminController.swift new file mode 100644 index 0000000..cd5f3c9 --- /dev/null +++ b/Sources/ManagableUsers/Controllers/AdminController.swift @@ -0,0 +1,132 @@ +import Vapor +import Leaf +import PostgresKit +import SwiftSMTPVapor + +public struct AdminController: Sendable where User.SessionID == ExpiringUserId { + public init() {} + + public func routes (_ router: any RoutesBuilder, api apiRouter: any RoutesBuilder) { + let admin = router + .grouped ("admin") + .grouped (RoleMiddleware (role: "admin")) + let api = apiRouter + .grouped ("admin") + .grouped (RoleAPIMiddleware (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) + } + } +} diff --git a/Sources/ManagableUsers/Controllers/AuthenticationController.swift b/Sources/ManagableUsers/Controllers/AuthenticationController.swift new file mode 100644 index 0000000..193d36f --- /dev/null +++ b/Sources/ManagableUsers/Controllers/AuthenticationController.swift @@ -0,0 +1,128 @@ +import Vapor +import FluentPostgresDriver +import SwiftSMTP + +public struct AuthenticationController: 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()).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: 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 + } +} diff --git a/Sources/ManagableUsers/ManagableUsers.swift b/Sources/ManagableUsers/ManagableUsers.swift new file mode 100644 index 0000000..0047654 --- /dev/null +++ b/Sources/ManagableUsers/ManagableUsers.swift @@ -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) + } +} diff --git a/Sources/ManagableUsers/Middleware/RoleMiddleware.swift b/Sources/ManagableUsers/Middleware/RoleMiddleware.swift new file mode 100644 index 0000000..6a216b0 --- /dev/null +++ b/Sources/ManagableUsers/Middleware/RoleMiddleware.swift @@ -0,0 +1,35 @@ +import Vapor + +public struct RoleMiddleware: 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: 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) + } +} diff --git a/Sources/ManagableUsers/Models/AdministeredUser.swift b/Sources/ManagableUsers/Models/AdministeredUser.swift new file mode 100644 index 0000000..0ae9ecc --- /dev/null +++ b/Sources/ManagableUsers/Models/AdministeredUser.swift @@ -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() + } +} diff --git a/Sources/ManagableUsers/Models/BasicUser.swift b/Sources/ManagableUsers/Models/BasicUser.swift new file mode 100644 index 0000000..cf25a2c --- /dev/null +++ b/Sources/ManagableUsers/Models/BasicUser.swift @@ -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" + } +} diff --git a/Sources/ManagableUsers/Models/ExpiringUserId.swift b/Sources/ManagableUsers/Models/ExpiringUserId.swift new file mode 100644 index 0000000..e531e1d --- /dev/null +++ b/Sources/ManagableUsers/Models/ExpiringUserId.swift @@ -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 + } + } +} diff --git a/Sources/ManagableUsers/Models/ManagedUser.swift b/Sources/ManagableUsers/Models/ManagedUser.swift new file mode 100644 index 0000000..d3fb42b --- /dev/null +++ b/Sources/ManagableUsers/Models/ManagedUser.swift @@ -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 { + return UserSessionAuthenticator() + } + + static func basicAuthenticator() -> UserAuthenticator { + 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() + } +} diff --git a/Sources/ManagableUsers/Models/UserRole.swift b/Sources/ManagableUsers/Models/UserRole.swift new file mode 100644 index 0000000..f06d7d4 --- /dev/null +++ b/Sources/ManagableUsers/Models/UserRole.swift @@ -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) + } +} diff --git a/Sources/ManagableUsers/Models/UserToken.swift b/Sources/ManagableUsers/Models/UserToken.swift new file mode 100644 index 0000000..6b905e6 --- /dev/null +++ b/Sources/ManagableUsers/Models/UserToken.swift @@ -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() + } +} diff --git a/Sources/ManagableUsers/Utilities/BaseURLTag.swift b/Sources/ManagableUsers/Utilities/BaseURLTag.swift new file mode 100644 index 0000000..62c7b43 --- /dev/null +++ b/Sources/ManagableUsers/Utilities/BaseURLTag.swift @@ -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) + } +} diff --git a/Sources/ManagableUsers/Utilities/Environment+Configuration.swift b/Sources/ManagableUsers/Utilities/Environment+Configuration.swift new file mode 100644 index 0000000..6d3e425 --- /dev/null +++ b/Sources/ManagableUsers/Utilities/Environment+Configuration.swift @@ -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 + } + } +} diff --git a/Sources/ManagableUsers/Utilities/HasRoleTag.swift b/Sources/ManagableUsers/Utilities/HasRoleTag.swift new file mode 100644 index 0000000..51df293 --- /dev/null +++ b/Sources/ManagableUsers/Utilities/HasRoleTag.swift @@ -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")) + } + } +} diff --git a/Sources/ManagableUsers/Utilities/MenuItemsTag.swift b/Sources/ManagableUsers/Utilities/MenuItemsTag.swift new file mode 100644 index 0000000..2bf5432 --- /dev/null +++ b/Sources/ManagableUsers/Utilities/MenuItemsTag.swift @@ -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 (#" \#(item.name)"#) + } else { + lines.append (#" \#(item.name)"#) + } + } + } + + return LeafData.string (lines.joined (separator: "\n")) + } + + private func activeClass (_ section: String?, _ expected: String) -> String { + if section == expected { + #" class="active""# + } else { + "" + } + } +} diff --git a/Sources/ManagableUsers/Utilities/MockDatabase.swift b/Sources/ManagableUsers/Utilities/MockDatabase.swift new file mode 100644 index 0000000..8a5c5a5 --- /dev/null +++ b/Sources/ManagableUsers/Utilities/MockDatabase.swift @@ -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 (_ 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 { + 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 (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 diff --git a/Sources/ManagableUsers/Utilities/Request+SQLDatabase.swift b/Sources/ManagableUsers/Utilities/Request+SQLDatabase.swift new file mode 100644 index 0000000..4e8f752 --- /dev/null +++ b/Sources/ManagableUsers/Utilities/Request+SQLDatabase.swift @@ -0,0 +1,14 @@ +import Fluent +import SQLKit + +extension Database { + public func withSQLConnection(_ 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) + } + } +} diff --git a/Sources/ManagableUsers/Utilities/SQLDatabase+Transaction.swift b/Sources/ManagableUsers/Utilities/SQLDatabase+Transaction.swift new file mode 100644 index 0000000..8046399 --- /dev/null +++ b/Sources/ManagableUsers/Utilities/SQLDatabase+Transaction.swift @@ -0,0 +1,20 @@ +import FluentKit +import SQLKit + +extension Database { + public func transaction(_ 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`" } +} diff --git a/Sources/SampleApp/configure.swift b/Sources/SampleApp/configure.swift new file mode 100644 index 0000000..2b55b38 --- /dev/null +++ b/Sources/SampleApp/configure.swift @@ -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) +} diff --git a/Sources/SampleApp/entrypoint.swift b/Sources/SampleApp/entrypoint.swift new file mode 100644 index 0000000..54568f9 --- /dev/null +++ b/Sources/SampleApp/entrypoint.swift @@ -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() + } +} diff --git a/Sources/SampleApp/routes.swift b/Sources/SampleApp/routes.swift new file mode 100644 index 0000000..c0c5546 --- /dev/null +++ b/Sources/SampleApp/routes.swift @@ -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().routes (sessioned, api: api) + AdminController().routes (loggedIn, api: api) + + loggedIn.get { req async throws in + try await req.view.render("welcome", ["user": req.auth.get (BasicUser.self)]) + } +} diff --git a/Tests/SampleAppTests/AdminTests.swift b/Tests/SampleAppTests/AdminTests.swift new file mode 100644 index 0000000..bcc4a17 --- /dev/null +++ b/Tests/SampleAppTests/AdminTests.swift @@ -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()])]) + 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()])]) + + 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) + } + } +} diff --git a/Tests/SampleAppTests/AuthorizationTests.swift b/Tests/SampleAppTests/AuthorizationTests.swift new file mode 100644 index 0000000..2f11afd --- /dev/null +++ b/Tests/SampleAppTests/AuthorizationTests.swift @@ -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()])]) + 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()])]) + + 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()])]) + 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()])]) + + 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) + } + } +}