diff options
Diffstat (limited to 'src/server.js')
| -rw-r--r-- | src/server.js | 327 |
1 files changed, 327 insertions, 0 deletions
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; +}; |