diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2025-12-23 22:14:44 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2025-12-23 22:17:09 +0100 |
| commit | aeb9ecadf80fd50290571071311877b39c80e962 (patch) | |
| tree | b9a5cbe979fbd4fb14c97b00534b6918df6b0d85 /src | |
Initial implementation1.0.0
Diffstat (limited to 'src')
| -rw-r--r-- | src/client.js | 203 | ||||
| -rw-r--r-- | src/server.js | 327 |
2 files changed, 530 insertions, 0 deletions
diff --git a/src/client.js b/src/client.js new file mode 100644 index 0000000..7c0de4a --- /dev/null +++ b/src/client.js @@ -0,0 +1,203 @@ +const internals = { + kStyleSheet: "/security/style.css", + kClientSettingsURL: "/auth/client-settings.json", + kOpenLockSVG: + '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none"><defs><clipPath id="a" class="frame-clip frame-clip-def"><rect width="128" height="128" rx="0" ry="0"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows" clip-path="url(#a)"><g class="fills"><rect width="128" height="128" class="frame-background" rx="0" ry="0"/></g><g class="frame-children"><rect width="90" height="72" x="19" y="56" class="fills" rx="4" ry="4" style="fill:currentColor"/><path d="M28 39c2-16 1.001-38 37-38s35 21 34.997 56h-12C88 30 89 13 65 13c-24 0-24 21-25 26H28" class="fills" style="fill:currentColor"/></g></g></g></g></svg>', + kLockSVG: + '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none"><defs><clipPath id="a" class="frame-clip frame-clip-def"><rect width="128" height="128" rx="0" ry="0"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows" clip-path="url(#a)"><g class="fills"><rect width="128" height="128" class="frame-background" rx="0" ry="0"/></g><g class="frame-children"><rect width="90" height="72" x="19" y="56" class="fills" rx="4" ry="4" style="fill:currentColor"/><path d="M28 57c0-25 1-48 37-48s35 23 35 48H88c0-17 1-36-23-36S40 40 40 57H28" class="fills" style="fill:currentColor"/></g></g></g></g></svg>', + kKeySVG: + '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none"><defs><clipPath id="a" class="frame-clip frame-clip-def"><rect width="128" height="128" rx="0" ry="0"/></clipPath></defs><g class="frame-container-wrapper"><g class="frame-container-blur"><g class="frame-container-shadows" clip-path="url(#a)"><g class="fills"><rect width="128" height="128" class="frame-background" rx="0" ry="0"/></g><g class="frame-children"><path d="M23.414 127.414A1.994 1.994 0 0 1 22 128H2c-1.104 0-2-.896-2-2v-20c0-.544.217-1.037.57-1.397l-.07-.07 48.392-48.392c-5.845-14.747-2.806-32.21 9.117-44.132 16-16.001 41.982-16.001 57.982 0 16.001 16 16.001 41.982 0 57.982-11.613 11.614-28.484 14.798-42.98 9.554L73 79.56a3.99 3.99 0 0 0-2.968 1.168l-5.304 5.304a4.022 4.022 0 0 0-.42.495l-5.423 5.422a2.02 2.02 0 0 1-1.385.551H46c-1.104 0-2 .896-2 2v11a4.004 4.004 0 0 1-3.108 3.9H33c-1.104 0-2 .896-2 2v8a1.913 1.913 0 0 1-.532.974l-6.788 6.789M105.14 22.859c-4-4-10.495-4-14.495 0s-4 10.495 0 14.495 10.495 4 14.495 0 4-10.495 0-14.495" class="fills" style="fill:currentColor"/></g></g></g></g></svg>', + + settings: {}, + + makeLinkWithIcon(href, id, classes, title, icon) { + const link = document.createElement("a"); + link.href = href; + link.id = id; + link.title = title; + link.classList.add(...classes); + link.innerHTML = icon; + + return link; + }, +}; + +const oidcSecurity = { + async setup() { + const hasStyleSheet = [...document.styleSheets].some((styleSheet) => + styleSheet.href.endsWith(internals.kStyleSheet), + ); + + if (!hasStyleSheet) { + console.log("Adding security stylesheet."); + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = internals.kStyleSheet; + link.type = "text/css"; + document.getElementsByTagName("head")[0].appendChild(link); + } + + const response = await fetch(internals.kClientSettingsURL, { + cache: "no-cache", + mode: "same-origin", + }); + + if (response.ok) { + const clientSettings = await response.json(); + window.isOwner = clientSettings.isOwner; + internals.settings = clientSettings; + + let dialogHost = window.location.hostname; + if (internals.settings.wikiHost) { + dialogHost = clientSettings.wikiHost; + } + + internals.settings.cookieDomain = dialogHost; + + if (window.location.port) { + dialogHost = `${dialogHost}:${window.location.port}`; + } + + let dialogProtocol = window.location.protocol; + if (internals.settings.useHttps) { + dialogProtocol = "https:"; + } + const dialogOrigin = `${dialogProtocol}//${dialogHost}`; + internals.settings.dialogOrigin = dialogOrigin; + internals.settings.dialogURL = `${dialogOrigin}${internals.settings.loginURL}`; + oidcSecurity.updateFooter(window.ownerName, window.isAuthenticated); + + // Subscription to the dialog messages. + window.addEventListener("message", async (event) => { + if (event.origin !== internals.settings.dialogOrigin) { + return; + } + if (event.data?.event === "loginDone") { + if ( + internals.settings.claimOnFirstLogin && + !window.isClaimed + ) { + await oidcSecurity.claimWiki(); + } + window.location.reload(); + } + }); + } else { + console.log("Unable to fetch client settings: ", response); + } + }, + + async claimWiki() { + if (!window.isClaimed) { + const response = await fetch(internals.settings.claimURL, { + cache: "no-cache", + mode: "same-origin", + credentials: "same-origin", + }); + if (response.ok) { + const json = await response.json(); + if (wiki.lineup.bestTitle() === "Login Required") { + location.reload(); + } else { + const { owner } = json; + window.isClaimed = true; + window.isOwner = true; + oidcSecurity.updateFooter(owner, true); + } + } else { + console.log("Attempt to claim site failed", response); + } + } + }, + + updateFooter(ownerName, isAuthenticated) { + if (ownerName) { + document.querySelector("footer > #site-owner").innerHTML = + `Site Owned by: <span id="site-owner">${ownerName}</span>`; + } + + document.querySelector("footer > #security").innerHTML = ""; + if (isAuthenticated) { + let logoutTitle = "Not Owner : Sign-out"; + let logoutIcon = internals.kLockSVG; + let logoutClass = ["footer-item", "notOwner"]; + if (window.isOwner) { + logoutTitle = "Sign-out"; + logoutIcon = internals.kOpenLockSVG; + logoutClass = ["footer-item"]; + } + + const logoutLink = internals.makeLinkWithIcon( + internals.settings.logoutURL, + "logout", + logoutClass, + logoutTitle, + logoutIcon, + ); + document.querySelector("footer > #security").append(logoutLink); + + logoutLink.addEventListener("click", async (event) => { + event.preventDefault(); + const response = await fetch(internals.settings.logoutURL, { + cache: "no-cache", + mode: "same-origin", + credentials: "same-origin", + }); + if (response.ok) { + window.isAuthenticated = false; + window.user = ""; + document.cookie = `state=loggedOut;domain=.${internals.settings.cookieDomain}; path=/; max-age=60; sameSite=Strict;`; + oidcSecurity.updateFooter(ownerName, false); + } else { + console.log("Logout failed: ", response); + } + }); + + if (!window.isClaimed) { + const claimLink = internals.makeLinkWithIcon( + internals.settings.claimURL, + "claim", + ["footer-item", "claim"], + "Claim this Wiki", + internals.kKeySVG, + ); + document.querySelector("footer > #security").append(claimLink); + + claimLink.addEventListener("click", (event) => { + event.preventDefault(); + oidcSecurity.claimWiki(); + }); + } + } else { + let signOnTitle = "Claim this wiki"; + if (window.isClaimed) { + signOnTitle = "Wiki Owner Sign-on"; + } + + const securityLink = internals.makeLinkWithIcon( + internals.settings.dialogURL, + "show-security-dialog", + ["footer-item"], + signOnTitle, + internals.kLockSVG, + ); + document.querySelector("footer > #security").append(securityLink); + + securityLink.addEventListener("click", (event) => { + event.preventDefault(); + + document.cookie = `wikiName=${window.location.host};domain=.${internals.settings.cookieDomain}; path=/; max-age=300; sameSite=Strict;`; + + // This dialog will emit a loginDone event when done. + // We listen to it to refresh. + window.open( + internals.settings.dialogURL, + "oidc", + "popup,height=600,width=375", + ); + }); + } + }, +}; + +window.plugins.security = oidcSecurity; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..a833dad --- /dev/null +++ b/src/server.js @@ -0,0 +1,327 @@ +const { access, constants, readFile, writeFile } = require("node:fs"); +const { join } = require("node:path"); +const { auth } = require("express-openid-connect"); + +const internals = { + kLogoutURL: "/logout", + kLoginURL: "/auth/login", + kClaimURL: "/auth/claim-wiki", + kSuccessfulLoginURL: "/auth/success", + buildBaseURL({ url, wiki_domain, security_useHttps }) { + let host = wiki_domain || url; + + if (host.includes("//")) { + return `${host}`; + } + + const protocol = security_useHttps ? "https" : "http"; + return `${protocol}://${host}`; + }, +}; + +/** + * Creates a security handler for the wiki. + * @param {(...args: any[]) => void} log a logger function pre-configured to + * only log if debug is on. + * @param {(...args: any[]) => void} loga a logger function that always logs. + * @argv {Object} argv The wiki configuration. + */ +module.exports = function createHandler( + log, + loga, + { + id: idFile, + url, + wiki_domain, + admin, + security_useHttps, + oidc_claimOnFirstLogin, + oidc_clientID, + oidc_clientSecret, + oidc_idpLogout, + oidc_issuerBaseURL, + oidc_issuerName, + oidc_secret, + oidc_logoutRedirectURL, + restricted, + restrictedDomains, + restrictedUsers, + }, +) { + let owner = null; + const security = { + /** + * Reads the owner file to determine the owner. + */ + retrieveOwner(callback) { + access(idFile, constants.F_OK, (error) => { + if (error) { + owner = null; + return callback(); + } + readFile(idFile, "utf8", (error, data) => { + if (error) { + owner = null; + return callback(error); + } + owner = JSON.parse(data); + callback(); + }); + }); + }, + + /** + * Reads the owner file to determine the owner. + */ + getOwner() { + return owner?.name || ""; + }, + + /** + * Sets a new owner. + */ + setOwner(newOwner, callback) { + access(idFile, constants.F_OK, (error) => { + if (!error) { + return callback(new Error("Site already claimed")); + } + + writeFile(idFile, JSON.stringify(newOwner), (error) => { + if (error) { + return callback( + new Error("Could not write owner to file."), + ); + } + loga(`Claiming wiki ${url} for ${newOwner.name}`); + owner = newOwner; + callback(); + }); + }); + }, + + /** + * Gets the current authenticated user. + */ + getUser(req) { + return req.oidc?.user || ""; + }, + + /** + * Checks if the current authenticated user is authorized. + */ + isAuthorized(req) { + if (owner === null) { + loga("isAuthorized: Site not claimed"); + return true; + } + + const preferredUsername = req.oidc.user?.preferred_username; + return preferredUsername && preferredUsername === owner.username; + }, + + /** + * Checks if the current authenticated user is an admin. + */ + isAdmin(req) { + if (!admin) { + return false; + } + if (!req.oidc?.user) { + return false; + } + + const preferredUsername = req.oidc.user?.preferred_username; + return preferredUsername && preferredUsername === admin; + }, + + /** + * Defines security routes for the wiki. + */ + defineRoutes(app, _, updateOwner) { + const viewsPath = join(__dirname, "..", "views"); + + const config = { + authRequired: false, + idpLogout: !!oidc_idpLogout, + baseURL: internals.buildBaseURL({ + url, + wiki_domain, + security_useHttps, + }), + clientID: oidc_clientID, + clientSecret: oidc_clientSecret, + clientAuthMethod: "client_secret_post", + issuerBaseURL: oidc_issuerBaseURL, + secret: oidc_secret, + enableTelemetry: false, + routes: { + login: false, + logout: false, + callback: "/auth/oidc/callback", + }, + }; + + app.use(auth(config)); + + // Starts the OIDC login process. + app.get("/auth/oidc", (_, res) => + res.oidc.login({ + returnTo: internals.kSuccessfulLoginURL, + }), + ); + + // Displays the login dialog. + app.get(internals.kLoginURL, (_, res) => { + res.render(join(viewsPath, "login.html"), { + issuerName: oidc_issuerName || "SSO", + wikiName: wiki_domain || url || "wiki", + }); + }); + + // Shown after a successful login. + app.get(internals.kSuccessfulLoginURL, (req, res) => { + if (!req.oidc.isAuthenticated()) { + return res.redirect(internals.kLoginURL); + } + + const username = + req.oidc.user.preferred_username || + req.oidc.user.name || + "User"; + res.render(join(viewsPath, "success.html"), { username }); + }); + + // Claims a wiki. + app.get(internals.kClaimURL, (req, res) => { + if (!req.oidc.isAuthenticated()) { + return res.status(401).json({ error: "Not authenticated" }); + } + + const user = req.oidc.user; + const newOwner = { + name: user.name || user.preferred_username || user.email, + username: user.preferred_username || user.email, + }; + + security.setOwner(newOwner, (error) => { + if (error) { + loga(`Failed to claim wiki: ${error.message}`); + return res.status(400).json({ error: error.message }); + } + log(`Wiki claimed by ${newOwner.name}`); + updateOwner(newOwner.name); + res.json({ success: true, owner: newOwner.name }); + }); + }); + + // Settings sent to the client. + app.get("/auth/client-settings.json", (req, res) => { + const authenticated = req.oidc.isAuthenticated(); + const settings = { + claimOnFirstLogin: !!oidc_claimOnFirstLogin, + logoutURL: internals.kLogoutURL, + loginURL: internals.kLoginURL, + claimURL: internals.kClaimURL, + wikiHost: wiki_domain, + useHttps: security_useHttps, + owner: owner ? owner.name : null, + isOwner: authenticated && security.isAuthorized(req), + }; + res.json(settings); + }); + + app.get(internals.kLogoutURL, (_, res) => { + const returnTo = oidc_logoutRedirectURL || "/"; + res.oidc.logout({ + returnTo, + }); + }); + + if (restricted) { + const allowedToView = (req) => { + if (!req.oidc?.user) { + return false; + } + + const user = req.oidc.user; + const username = user.preferred_username || user.email; + + if (restrictedDomains) { + const domains = restrictedDomains + .split(",") + .map((d) => d.trim()); + const email = user.email || ""; + const emailDomain = email.split("@")[1]; + + if (domains.includes(emailDomain)) { + return true; + } + } + + if (restrictedUsers) { + const users = restrictedUsers + .split(",") + .map((u) => u.trim()); + + if (users.includes("*") || users.includes(username)) { + return true; + } + } + + return false; + }; + + app.all("*", (req, res, next) => { + // Site flags are public. + if (req.url === "/favicon.png") { + return next(); + } + if (!/\.(json|html)$/.test(req.url)) { + return next(); + } + + res.header( + "Access-Control-Allow-Origin", + req.get("Origin") || "*", + ); + res.header("Access-Control-Allow-Credentials", "true"); + + if ( + (security.isAuthorized(req) && owner !== null) || + allowedToView(req) + ) { + return next(); + } + + // Allow HTML views. + const htmlMatch = req.url.match(/\/(.*)\.html/); + if (htmlMatch) { + return res.redirect(`/view/${htmlMatch[1]}`); + } + + // Sitemap is public. + if (req.url === "/system/sitemap.json") { + return res.json(["Login Required"]); + } + + // If not allowed, show the login page. + const problem = + "This is a restricted wiki requires users to login to view pages. You do not have to be the site owner but you do need to login with a participating email address."; + const details = `[http://ward.asia.wiki.org/login-to-view.html details]`; + res.status(200).json({ + title: "Login Required", + story: [ + { + type: "paragraph", + id: "55d44b367ed64875", + text: `${problem} ${details}`, + }, + ], + }); + }); + } + }, + }; + + return security; +}; |