From: Justin Wind Date: Sat, 23 Oct 2021 20:56:31 +0000 (-0700) Subject: IndieAuth login support, allows viewing of topics related to profile X-Git-Tag: v1.3.0^2~1 X-Git-Url: https://git.squeep.com/?a=commitdiff_plain;h=d0444b4b0ee8166d911e6b227218d110eb7eddf1;p=websub-hub IndieAuth login support, allows viewing of topics related to profile Admin access is now session-based, using an encrypted cookie. Added local system PAM authentication option, as well. --- diff --git a/README.md b/README.md index c7e805a..20f61e0 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Database table initialization and schema version migrations are automated. Conf A user will need to be created in order to view the `/admin` pages; the `bin/authAddUser.js` script will do this. +An IndieAuth profile may be used to view any topics associated with that profile. + The bundled logger spews JSON to stdout. ### Quickstart Example @@ -126,6 +128,7 @@ This implementation is built atop an in-house API framework, for Reasons. It wo - *.js - environment specific values, edit these as needed - server.js - launches the application server - src/ + - authenticator.js - interact with credentials and validation mechanisms - common.js - utility functions - communication.js - outgoing requests and associated logic - db/ @@ -146,6 +149,7 @@ This implementation is built atop an in-house API framework, for Reasons. It wo - logger.js - a very simple logging class - manager.js - process incoming requests - service.js - defines incoming endpoints, linking the API server framework to the manager methods + - session-manager.js - process login/logout requests - template/ - HTML content - worker.js - maintains a pool of tasks in progress, for sending out updates, performing verifications, et cetera - test/ - unit and coverage tests diff --git a/config/default.js b/config/default.js index 64f45ec..3714253 100644 --- a/config/default.js +++ b/config/default.js @@ -9,6 +9,9 @@ const defaultOptions = { // Uniquely identify this instance, used to tag work-in-progress. nodeId: common.requestId(), // Default to ephemeral ID: easiest for clustered deployments. + // This should be set to a reasonably long passphrase or random buffer, to keep client session data secure. + encryptionSecret: undefined, // REQUIRED + // Dingus API Server Framework options. Be sure to set the one REQUIRED option here. dingus: { // This needs to be the full externally accessible root URL, including any proxyPrefix component, which clients will connect to, and which topics will list as their hub link. @@ -69,6 +72,8 @@ const defaultOptions = { authenticator: { basicRealm: packageName, // Realm prompt for login on administration pages secureAuthOnly: true, // Require secure transport for authentication. + authnEnabled: ['argon2', 'pam'], + forbiddenPAMIdentifiers: ['root'], }, worker: { diff --git a/config/test.js b/config/test.js index 3ff1259..081f32a 100644 --- a/config/test.js +++ b/config/test.js @@ -10,4 +10,5 @@ module.exports = { queryLogLevel: 'debug', cacheEnabled: false, }, + encryptionSecret: 'not a very good secret', }; diff --git a/package-lock.json b/package-lock.json index 02f015e..55312c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -535,10 +535,10 @@ "dev": true }, "@squeep/api-dingus": { - "version": "git+https://git.squeep.com/squeep-api-dingus/#842a9b1e5b62aa642a53269a8466fd1e021e4ff2", - "from": "git+https://git.squeep.com/squeep-api-dingus/#v1.2.0", + "version": "git+https://git.squeep.com/squeep-api-dingus/#3cf325b9e87b66e16f05c9bcae769eea72b207ed", + "from": "git+https://git.squeep.com/squeep-api-dingus/#v1.2.1", "requires": { - "mime-db": "^1.49.0", + "mime-db": "^1.50.0", "uuid": "^8.3.2" }, "dependencies": { @@ -559,6 +559,10 @@ "microformats-parser": "^1.4.0" }, "dependencies": { + "@squeep/web-linking": { + "version": "git+https://git.squeep.com/squeep-web-linking/#3436c07c25324507228f3d538d345ea35751c623", + "from": "git+https://git.squeep.com/squeep-web-linking/#v1.0.0" + }, "axios": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz", @@ -570,12 +574,12 @@ } }, "@squeep/mystery-box": { - "version": "git+https://git.squeep.com/squeep-mystery-box/#044615f53bacdc366b44941218d808c549607469", - "from": "git+https://git.squeep.com/squeep-mystery-box/#v1.0.0" + "version": "git+https://git.squeep.com/squeep-mystery-box/#8df7723f7bbd9ad239bd5a3f66d6e9a8cd3c8100", + "from": "git+https://git.squeep.com/squeep-mystery-box/#v1.0.1" }, "@squeep/web-linking": { - "version": "git+https://git.squeep.com/squeep-web-linking/#6506e54d8183d383fa9c127b87783179a0662fc1", - "from": "git+https://git.squeep.com/squeep-web-linking/#v1.0.0" + "version": "git+https://git.squeep.com/squeep-web-linking/#0728e2b9347826ed0be7f9a7939bf5d61e181204", + "from": "git+https://git.squeep.com/squeep-web-linking/#v1.0.1" }, "@ungap/promise-all-settled": { "version": "1.1.2", @@ -1242,9 +1246,9 @@ "dev": true }, "eslint": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.0.1.tgz", - "integrity": "sha512-LsgcwZgQ72vZ+SMp4K6pAnk2yFDWL7Ti4pJaRvsZ0Hsw2h8ZjUIW38a9AFn2cZXdBMlScMFYYgsSp4ttFI/0bA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.1.0.tgz", + "integrity": "sha512-JZvNneArGSUsluHWJ8g8MMs3CfIEzwaLx9KyH4tZ2i+R2/rPWzL8c0zg3rHdwYVpN/1sB9gqnjHwz9HoeJpGHw==", "dev": true, "requires": { "@eslint/eslintrc": "^1.0.3", @@ -2211,9 +2215,9 @@ } }, "mime-db": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.49.0.tgz", - "integrity": "sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==" + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.50.0.tgz", + "integrity": "sha512-9tMZCDlYHqeERXEHO9f/hKfNXhre5dK2eE/krIvUjZbS2KPcqGDfNShIWS1uW9XOTKQKqK6qbeOci18rbfW77A==" }, "mimic-response": { "version": "2.1.0", diff --git a/package.json b/package.json index 8297e5f..22b3cf2 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,10 @@ "coverage-check" ], "dependencies": { - "@squeep/api-dingus": "git+https://git.squeep.com/squeep-api-dingus/#v1.2.0", + "@squeep/api-dingus": "git+https://git.squeep.com/squeep-api-dingus/#v1.2.1", "@squeep/indieauth-helper": "git+https://git.squeep.com/squeep-indieauth-helper/#v1.0.0", - "@squeep/mystery-box": "git+https://git.squeep.com/squeep-mystery-box/#v1.0.0", - "@squeep/web-linking": "git+https://git.squeep.com/squeep-web-linking/#v1.0.0", + "@squeep/mystery-box": "git+https://git.squeep.com/squeep-mystery-box/#v1.0.1", + "@squeep/web-linking": "git+https://git.squeep.com/squeep-web-linking/#v1.0.1", "axios": "^0.23.0", "feedparser": "^2.2.10", "htmlparser2": "^7.1.2", @@ -48,7 +48,7 @@ "pg-promise": "^10.11.1" }, "devDependencies": { - "eslint": "^8.0.1", + "eslint": "^8.1.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-security": "^1.4.0", "eslint-plugin-sonarjs": "^0.10.0", diff --git a/src/authenticator.js b/src/authenticator.js index 7053b56..3a196fc 100644 --- a/src/authenticator.js +++ b/src/authenticator.js @@ -1,18 +1,48 @@ 'use strict'; -const argon2 = require('argon2'); const common = require('./common'); const Enum = require('./enum'); const Errors = require('./errors'); +const { MysteryBox } = require('@squeep/mystery-box'); const _fileScope = common.fileScope(__filename); class Authenticator { + /** + * @param {Console} logger + * @param {*} db + * @param {Object} options + * @param {Object} options.authenticator + * @param {String} options.authenticator.basicRealm + * @param {Boolean} options.authenticator.secureAuthOnly + * @param {String[]} options.authenticator.forbiddenPAMIdentifiers + * @param {String[]} options.authenticator.authnEnabled + */ constructor(logger, db, options) { this.logger = logger; this.db = db; this.basicRealm = options.authenticator.basicRealm; this.secureAuthOnly = options.authenticator.secureAuthOnly; + + this.authn = { + DEBUG_ANY: {}, + }; + try { + this.authn.argon2 = require('argon2'); + } catch (e) { /**/ } + try { + this.authn.pam = require('node-linux-pam'); + this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers; + } catch (e) { /**/ } + + this.authnEnabled = Object.keys(this.authn).filter((auth) => options.authenticator.authnEnabled.includes(auth)); + this.logger.debug(_fileScope('constructor'), 'available mechanisms', { authn: this.authnEnabled }); + + if (this.authnEnabled.length === 0) { + throw new Error('no authentication mechanisms available'); + } + + this.mysteryBox = new MysteryBox(logger, options); } @@ -28,26 +58,74 @@ class Authenticator { const [identifier, credential] = common.splitFirst(credentials, ':', ''); - let valid = false; + return this.isValidIdentifierCredential(identifier, credential, ctx); + } + + + /** + * Check local auth entries. + * @param {String} identifier + * @param {String} credential + * @param {Object} ctx + */ + async isValidIdentifierCredential(identifier, credential, ctx) { + const _scope = _fileScope('isValidIdentifierCredential'); + this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat(credential.length), ctx }); + + let isValid = false; + await this.db.context(async (dbCtx) => { const authData = await this.db.authenticationGet(dbCtx, identifier); if (!authData) { - this.logger.debug(_scope, 'failed, invalid authentication id', { ctx }); - return false; + this.logger.debug(_scope, 'failed, invalid identifier', { ctx, identifier }); + } else { + if (authData.credential.startsWith('$argon2') + && this.authnEnabled.includes('argon2')) { + isValid = await this.authn.argon2.verify(authData.credential, credential); + } else if (authData.credential.startsWith('$PAM$') + && this.authnEnabled.includes('pam')) { + isValid = this._isValidPAMIdentifier(identifier, credential); + } else { + this.logger.error(_scope, 'failed, unknown type of stored credential', { identifier, ctx }); + } } - if (authData.credential.startsWith('$argon2')) { - valid = await argon2.verify(authData.credential, credential); - } else { - this.logger.error(_scope, 'failed, unknown type of stored password hash', { ctx }); + if (this.authnEnabled.includes('DEBUG_ANY')) { + isValid = true; } - if (valid) { + + if (isValid) { ctx.authenticationId = identifier; await this.db.authenticationSuccess(dbCtx, identifier); } - }); + }); // dbCtx + + return isValid; + } - return valid; + + /** + * Check system PAM. + * @param {String} identifier + * @param {String} credential + * @returns {Boolean} + */ + async _isValidPAMIdentifier(identifier, credential) { + const _scope = _fileScope('_isValidPAMIdentifier'); + let isValid = false; + if (this.forbiddenPAMIdentifiers.includes(identifier)) { + return false; + } + try { + await this.authn.pam.pamAuthenticatePromise({ username: identifier, password: credential }); + isValid = true; + } catch (e) { + this.logger.debug(_scope, 'failed', { error: e }); + if (!(e instanceof this.authn.pam.PamError)) { + throw e; + } + } + return isValid; } @@ -65,7 +143,7 @@ class Authenticator { switch (authMethod.toLowerCase()) { case 'basic': { const credentials = Buffer.from(authString, 'base64').toString('utf-8'); - return await this.isValidBasic(credentials, ctx); + return this.isValidBasic(credentials, ctx); } default: @@ -85,13 +163,41 @@ class Authenticator { } + /** + * Attempt to parse a session cookie, and determine if it + * contains authenticated user. + * Restores ctx.session from cookie data. + * @param {Object} ctx + * @param {String} cookieHeader + * @returns {Boolean} + */ + async isValidCookieAuth(ctx, cookieHeader) { + const _scope = _fileScope('isValidCookieAuth'); + this.logger.debug(_scope, 'called', { ctx, cookieHeader }); + + const [ cookieName, cookieValue ] = common.splitFirst(cookieHeader, '=', ''); + if (cookieName !== 'WSHas') { + return false; + } + try { + ctx.session = await this.mysteryBox.unpack(cookieValue); + this.logger.debug(_scope, 'unpacked cookie', { ctx }); + return !!ctx.session.authenticatedProfile || !! ctx.session.authenticatedIdentifier; + } catch (e) { + this.logger.debug(_scope, 'could not unpack cookie', { error:e, ctx }); + return false; + } + } + + /** * Require that a request has valid auth over secure channel, requests if missing. * @param {http.ClientRequest} req * @param {http.ServerResponse} res * @param {Object} ctx + * @param {String} loginPath */ - async required(req, res, ctx) { + async required(req, res, ctx, loginPath) { const _scope = _fileScope('required'); this.logger.debug(_scope, 'called', { ctx }); @@ -100,12 +206,69 @@ class Authenticator { throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication required, but connection is insecure; cannot continue'); } + const sessionCookie = req.getHeader(Enum.Header.Cookie); + if (sessionCookie && await this.isValidCookieAuth(ctx, sessionCookie)) { + return true; + } + const authData = req.getHeader(Enum.Header.Authorization); - if (authData - && await this.isValidAuthorization(authData, ctx)) { + if (authData) { + if (await this.isValidAuthorization(authData, ctx)) { + return true; + } + // If they came in trying header auth, let them try again. + return this.requestBasic(res); + } + + // Otherwise redirect to login. + res.statusCode = 302; + res.setHeader(Enum.Header.Location, loginPath); + res.end(); + + return false; + } + + + /** + * Require that a request has valid local auth over secure channel, requests if missing. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {Object} ctx + * @param {String} loginPath + */ + async requiredLocal(req, res, ctx, loginPath) { + const _scope = _fileScope('requiredLocal'); + this.logger.debug(_scope, 'called', { ctx }); + + if (this.secureAuthOnly && ctx.clientProtocol.toLowerCase() !== 'https') { + this.logger.debug(_scope, 'rejecting insecure auth', ctx); + throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication required, but connection is insecure; cannot continue'); + } + + // Only accept identifier sessions. + const sessionCookie = req.getHeader(Enum.Header.Cookie); + if (sessionCookie + && await this.isValidCookieAuth(ctx, sessionCookie) + && ctx.session.authenticatedIdentifier) { return true; } - return this.requestBasic(res); + + // Allow header auth + const authData = req.getHeader(Enum.Header.Authorization); + if (authData) { + if (await this.isValidAuthorization(authData, ctx)) { + return true; + } + // If they came in trying header auth, let them try again. + return this.requestBasic(res); + } + + // Otherwise redirect to login. + res.statusCode = 302; + res.setHeader(Enum.Header.Location, loginPath); + res.end(); + + return false; } } diff --git a/src/enum.js b/src/enum.js index 96f0940..b8eaf3e 100644 --- a/src/enum.js +++ b/src/enum.js @@ -15,10 +15,12 @@ const Enum = common.mergeDeep(DingusEnum, { Header: { Authorization: 'Authorization', + Cookie: 'Cookie', From: 'From', LastSeen: 'Last-Seen', Link: 'Link', Location: 'Location', + SetCookie: 'Set-Cookie', Signature: 'Signature', UserAgent: 'User-Agent', WWWAuthenticate: 'WWW-Authenticate', diff --git a/src/manager.js b/src/manager.js index 2aa9abc..667fb30 100644 --- a/src/manager.js +++ b/src/manager.js @@ -577,6 +577,15 @@ class Manager { }); this.logger.debug(_scope, 'got topics', { topics: ctx.topics }); + // Profile users can only see related topics. + if (ctx.session && ctx.session.authenticatedProfile) { + const profileUrlObj = new URL(ctx.session.authenticatedProfile); + ctx.topics = ctx.topics.filter((topic) => { + const topicUrlObj = new URL(topic.url); + return (topicUrlObj.hostname === profileUrlObj.hostname); + }); + } + res.end(Template.adminOverviewHTML(ctx, this.options)); this.logger.info(_scope, 'finished', { ...ctx, topics: ctx.topics.length }) } @@ -598,8 +607,18 @@ class Manager { }); this.logger.debug(_scope, 'got topic details', { topic: ctx.topic, subscriptions: ctx.subscriptions }); + // Profile users can only see related topics. + if (ctx.session && ctx.session.authenticatedProfile) { + const profileUrlObj = new URL(ctx.session.authenticatedProfile); + const topicUrlObj = new URL(ctx.topic.url); + if (topicUrlObj.hostname !== profileUrlObj.hostname) { + ctx.topic = null; + ctx.subscriptions = []; + } + } + res.end(Template.adminTopicDetailsHTML(ctx, this.options)); - this.logger.info(_scope, 'finished', { ...ctx, subscriptions: ctx.subscriptions.length, topic: ctx.topic.id }); + this.logger.info(_scope, 'finished', { ...ctx, subscriptions: ctx.subscriptions.length, topic: ctx.topic && ctx.topic.id || ctx.topic }); } diff --git a/src/service.js b/src/service.js index df56ba0..1d9b8a0 100644 --- a/src/service.js +++ b/src/service.js @@ -9,6 +9,7 @@ const { Dingus } = require('@squeep/api-dingus'); const common = require('./common'); const Enum = require('./enum'); const Manager = require('./manager'); +const SessionManager = require('./session-manager'); const Authenticator = require('./authenticator'); const path = require('path'); @@ -23,7 +24,9 @@ class Service extends Dingus { this.manager = new Manager(logger, db, options); this.authenticator = new Authenticator(logger, db, options); + this.sessionManager = new SessionManager(logger, this.authenticator, options); this.staticPath = path.join(__dirname, '..', 'static'); + this.loginPath = `${options.dingus.proxyPrefix}/admin/login`; // Primary API endpoint this.on('POST', '/', this.handlerPostRoot.bind(this)); @@ -57,13 +60,20 @@ class Service extends Dingus { // Private server-action endpoints this.on('POST', '/admin/process', this.handlerPostAdminProcess.bind(this)); + + // Admin login + this.on(['GET', 'HEAD'], '/admin/login', this.handlerGetAdminLogin.bind(this)); + this.on(['POST'], '/admin/login', this.handlerPostAdminLogin.bind(this)); + this.on(['GET'], '/admin/logout', this.handlerGetAdminLogout.bind(this)); + this.on(['GET'], '/admin/_ia', this.handlerGetAdminIA.bind(this)); + } /** - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {Object} ctx */ async handlerPostRoot(req, res, ctx) { const _scope = _fileScope('handlerPostRoot'); @@ -77,9 +87,9 @@ class Service extends Dingus { /** - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {Object} ctx */ async handlerGetRoot(req, res, ctx) { const _scope = _fileScope('handlerGetRoot'); @@ -97,9 +107,9 @@ class Service extends Dingus { /** - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {object} ctx + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {Object} ctx */ async handlerGetHealthcheck(req, res, ctx) { const _scope = _fileScope('handlerGetHealthcheck'); @@ -116,7 +126,7 @@ class Service extends Dingus { /** * @param {http.ClientRequest} req * @param {http.ServerResponse} res - * @param {object} ctx + * @param {Object} ctx */ async handlerGetInfo(req, res, ctx) { const _scope = _fileScope('handlerGetInfo'); @@ -135,7 +145,7 @@ class Service extends Dingus { /** * @param {http.ClientRequest} req * @param {http.ServerResponse} res - * @param {object} ctx + * @param {Object} ctx */ async handlerGetAdminOverview(req, res, ctx) { const _scope = _fileScope('handlerGetAdminOverview'); @@ -145,7 +155,7 @@ class Service extends Dingus { this.setResponseType(this.responseTypes, req, res, ctx); - await this.authenticator.required(req, res, ctx); + await this.authenticator.required(req, res, ctx, this.loginPath); await this.manager.getAdminOverview(res, ctx); } @@ -154,7 +164,7 @@ class Service extends Dingus { /** * @param {http.ClientRequest} req * @param {http.ServerResponse} res - * @param {object} ctx + * @param {Object} ctx */ async handlerGetAdminTopicDetails(req, res, ctx) { const _scope = _fileScope('handlerGetAdminTopicDetails'); @@ -164,7 +174,7 @@ class Service extends Dingus { this.setResponseType(this.responseTypes, req, res, ctx); - await this.authenticator.required(req, res, ctx); + await this.authenticator.required(req, res, ctx, this.loginPath); await this.manager.getTopicDetails(res, ctx); } @@ -197,7 +207,7 @@ class Service extends Dingus { this.setResponseType(this.responseTypes, req, res, ctx); - await this.authenticator.required(req, res, ctx); + await this.authenticator.requiredLocal(req, res, ctx, this.loginPath); await this.maybeIngestBody(req, res, ctx); ctx.method = req.method; @@ -216,7 +226,7 @@ class Service extends Dingus { this.setResponseType(this.responseTypes, req, res, ctx); - await this.authenticator.required(req, res, ctx); + await this.authenticator.requiredLocal(req, res, ctx, this.loginPath); await this.maybeIngestBody(req, res, ctx); ctx.method = req.method; @@ -227,7 +237,7 @@ class Service extends Dingus { /** * @param {http.ClientRequest} req * @param {http.ServerResponse} res - * @param {object} ctx + * @param {Object} ctx */ async handlerPostAdminProcess(req, res, ctx) { const _scope = _fileScope('handlerPostAdminProcess'); @@ -235,10 +245,78 @@ class Service extends Dingus { this.setResponseType(this.responseTypes, req, res, ctx); - await this.authenticator.required(req, res, ctx); + await this.authenticator.requiredLocal(req, res, ctx, this.loginPath); await this.manager.processTasks(res, ctx); } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async handlerGetAdminLogin(req, res, ctx) { + const _scope = _fileScope('handlerGetAdminLogin'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + Dingus.setHeadHandler(req, res, ctx); + + this.setResponseType(this.responseTypes, req, res, ctx); + + await this.sessionManager.getAdminLogin(res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async handlerPostAdminLogin(req, res, ctx) { + const _scope = _fileScope('handlerPostAdminLogin'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + this.setResponseType(this.responseTypes, req, res, ctx); + + await this.maybeIngestBody(req, res, ctx); + + await this.sessionManager.postAdminLogin(res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async handlerGetAdminLogout(req, res, ctx) { + const _scope = _fileScope('handlerGetAdminLogout'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + this.setResponseType(this.responseTypes, req, res, ctx); + + await this.sessionManager.getAdminLogout(res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async handlerGetAdminIA(req, res, ctx) { + const _scope = _fileScope('handlerGetAdminIA'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + this.setResponseType(this.responseTypes, req, res, ctx); + + // Special case here, to see cookie before session established + ctx.cookie = req.getHeader(Enum.Header.Cookie); + + await this.sessionManager.getAdminIA(res, ctx); + } + } module.exports = Service; diff --git a/src/session-manager.js b/src/session-manager.js new file mode 100644 index 0000000..5caff34 --- /dev/null +++ b/src/session-manager.js @@ -0,0 +1,279 @@ +'use strict'; + +/** + * Here we process activities which support login sessions. + */ + +const { Communication: IndieAuthCommunication } = require('@squeep/indieauth-helper'); +const { MysteryBox } = require('@squeep/mystery-box'); +const common = require('./common'); +const Enum = require('./enum'); +const Template = require('./template'); + +const _fileScope = common.fileScope(__filename); + +class SessionManager { + constructor(logger, authenticator, options) { + this.logger = logger; + this.authenticator = authenticator; + this.options = options; + this.indieAuthCommunication = new IndieAuthCommunication(logger, options); + this.mysteryBox = new MysteryBox(logger, options); + + this.secureCookie = options.authenticator.secureAuthOnly ? ' Secure;' : ''; + this.cookieLifespan = 60 * 60 * 24 * 32; + } + + + /** + * Set or update our session cookie. + * @param {http.ServerResponse} res + * @param {Object} session + * @param {Number} maxAge + */ + async _sessionCookieSet(res, session, maxAge) { + const cookieName = 'WSHas'; + const secureSession = session && await this.mysteryBox.pack(session) || ''; + const cookie = [ + `${cookieName}=${secureSession}`, + 'HttpOnly', + this.secureCookie, + `Max-Age: ${maxAge}`, + ].join('; '); + res.setHeader(Enum.Header.SetCookie, cookie); + } + + + /** + * GET request for establishing admin session. + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async getAdminLogin(res, ctx) { + const _scope = _fileScope('getAdminLogin'); + this.logger.debug(_scope, 'called', { ctx }); + + res.end(Template.adminLoginHTML(ctx, this.options)); + this.logger.info(_scope, 'finished', { ctx }) + } + + + /** + * POST request for taking form data to establish admin session. + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async postAdminLogin(res, ctx) { + const _scope = _fileScope('postAdminLogin'); + this.logger.debug(_scope, 'called', { ctx }); + + ctx.errors = []; + + // Only attempt user login if no IndieAuth profile is set + if (!ctx.parsedBody['me']) { + this.logger.debug(_scope, 'no indieauth profile, trying identifier', { ctx }); + + const identifier = ctx.parsedBody['identifier']; + const credential = ctx.parsedBody['credential']; + + const isValidLocalIdentifier = await this.authenticator.isValidIdentifierCredential(identifier, credential, ctx); + if (!isValidLocalIdentifier) { + ctx.errors.push('Invalid username or password'); + } + + if (ctx.errors.length) { + res.end(Template.adminLoginHTML(ctx, this.options)); + return; + } + + // Valid auth, persist the authenticated session + ctx.session = { + authenticatedIdentifier: ctx.authenticationId, + }; + await this._sessionCookieSet(res, ctx.session, this.cookieLifespan); + res.statusCode = 302; + res.setHeader(Enum.Header.Location, './'); + res.end(); + this.logger.info(_scope, 'finished local', { ctx }); + return; + } + + let me, session, authorizationEndpoint; + try { + me = new URL(ctx.parsedBody['me']); + } catch (e) { + this.logger.debug(_scope, 'failed to parse supplied profile url', { ctx }); + ctx.errors.push(`Unable to understand '${ctx.parsedBody['me']}' as a profile URL.`); + } + + if (me) { + const profile = await this.indieAuthCommunication.fetchProfile(me); + if (!profile || !profile.authorizationEndpoint) { + this.logger.debug(_scope, 'failed to find any profile information at url', { ctx }); + ctx.errors.push(`No profile information was found at '${me}'.`); + } else { + // fetch and parse me for 'authorization_endpoint' relation links + try { + authorizationEndpoint = new URL(profile.authorizationEndpoint); + } catch (e) { + ctx.errors.push(`Unable to understand the authorization endpoint ('${profile.authorizationEndpoint}') indicated by that profile ('${me}') as a URL.`); + } + } + + if (authorizationEndpoint) { + const pkce = await IndieAuthCommunication.generatePKCE(); + session = { + authorizationEndpoint: authorizationEndpoint.href, + state: ctx.requestId, + codeVerifier: pkce.codeVerifier, + me, + }; + + Object.entries({ + 'response_type': 'code', + 'client_id': this.options.dingus.selfBaseUrl, + 'redirect_uri': `${this.options.dingus.selfBaseUrl}admin/_ia`, + 'state': session.state, + 'code_challenge': pkce.codeChallenge, + 'code_challenge_method': pkce.codeChallengeMethod, + 'me': me, + }).forEach(([name, value]) => authorizationEndpoint.searchParams.set(name, value)); + } + } + + if (ctx.errors.length) { + res.end(Template.adminLoginHTML(ctx, this.options)); + return; + } + + await this._sessionCookieSet(res, session, this.cookieLifespan); + res.setHeader(Enum.Header.Location, authorizationEndpoint.href); + res.statusCode = 302; // Found + res.end(); + + this.logger.info(_scope, 'finished indieauth', { ctx }) + } + + + /** + * GET request to remove current credentials. + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async getAdminLogout(res, ctx) { + const _scope = _fileScope('getAdminLogout'); + this.logger.debug(_scope, 'called', { ctx }); + + this._sessionCookieSet(res, '', 0); + res.statusCode = 302; + res.setHeader(Enum.Header.Location, './'); + res.end(); + + this.logger.info(_scope, 'finished', { ctx }); + } + + + /** + * GET request for returning IndieAuth redirect. + * @param {http.ServerResponse} res + * @param {Object} ctx + */ + async getAdminIA(res, ctx) { + const _scope = _fileScope('getAdminIA'); + this.logger.debug(_scope, 'called', { ctx }); + + ctx.errors = []; + ctx.session = {}; + + // Unpack cookie to restore session data + + const [ cookieName, cookieValue ] = common.splitFirst((ctx.cookie || ''), '=', ''); + if (cookieName !== 'WSHas') { + this.logger.debug(_scope, 'no cookie', { ctx }); + ctx.errors.push('missing required cookie'); + } else { + try { + ctx.session = await this.mysteryBox.unpack(cookieValue); + this.logger.debug(_scope, 'restored session from cookie', { ctx }); + } catch (e) { + this.logger.debug(_scope, 'could not unpack cookie'); + ctx.errors.push('invalid cookie'); + } + } + + // Validate unpacked session values + + // Add any auth errors + if (ctx.queryParams['error']) { + ctx.errors.push(ctx.queryParams['error']); + if (ctx.queryParams['error_description']) { + ctx.errors.push(ctx.queryParams['error_description']); + } + } + + // check stuff + if (ctx.queryParams['state'] !== ctx.session.state) { + this.logger.debug(_scope, 'state mismatch', { ctx }); + ctx.errors.push('invalid state'); + } + + const code = ctx.queryParams['code']; + if (!code) { + this.logger.debug(_scope, 'missing code', { ctx }); + ctx.errors.push('invalid code'); + } + + let redeemProfileUrl; + try { + redeemProfileUrl = new URL(ctx.session.authorizationEndpoint); + } catch (e) { + this.logger.debug(_scope, 'failed to parse restored session authorization endpoint as url', { ctx }); + ctx.errors.push('invalid cookie'); + } + let profile; + if (redeemProfileUrl) { + profile = await this.indieAuthCommunication.redeemProfileCode(redeemProfileUrl, code, ctx.session.codeVerifier, this.options.dingus.selfBaseUrl, `${this.options.dingus.selfBaseUrl}admin/_ia`); + if (!profile) { + this.logger.debug(_scope, 'no profile from code redemption', { ctx }); + ctx.errors.push('did not get a profile response from authorization endpoint code redemption'); + } else if (!profile.me) { + this.logger.debug(_scope, 'no profile me identifier from code redemption', { ctx }); + ctx.errors.push('did not get \'me\' value from authorization endpoint code redemption'); + } else if (profile.me !== ctx.session.me) { + this.logger.debug(_scope, 'mis-matched canonical me from redeemed profile', { ctx, profile }); + const newProfileUrl = new URL(profile.me); + // Rediscover auth endpoint for the new returned profile. + const newProfile = await this.indieAuthCommunication.fetchProfile(newProfileUrl); + if (newProfile.authorizationEndpoint !== ctx.session.authorizationEndpoint) { + this.logger.debug(_scope, 'mis-matched auth endpoints between provided me and canonical me', { ctx, profile, newProfile }); + ctx.errors.push('canonical profile url provided by authorization endpoint is not handled by that endpoint, cannot continue'); + } else { + // The endpoints match, all is okay, update our records. + ctx.session.me = profile.me; + } + } + } + + if (ctx.errors.length) { + await this._sessionCookieSet(res, '', 0); + res.end(Template.adminIAHTML(ctx, this.options)); + return; + } + + // set cookie as auth valid, redirect to admin + ctx.session = { + authenticatedProfile: ctx.session.me, + }; + + await this._sessionCookieSet(res, ctx.session, this.cookieLifespan); + res.statusCode = 302; + res.setHeader(Enum.Header.Location, './'); + res.end(); + + this.logger.info(_scope, 'finished', { ctx }) + } + + +} + +module.exports = SessionManager; \ No newline at end of file diff --git a/src/template/admin-ia-html.js b/src/template/admin-ia-html.js new file mode 100644 index 0000000..103582d --- /dev/null +++ b/src/template/admin-ia-html.js @@ -0,0 +1,39 @@ +'use strict'; + +const th = require('./template-helper'); + +function errorsSection(ctx) { + return (ctx.errors && ctx.errors.length) ? `
+

Troubles

+

Problems were encountered while trying to authenticate your profile URL.

+ +
+
+ Try Again? +
` + : ''; +} + +/** + * Render any errors from attempting IndieAuth. + * @param {Object} ctx + * @param {String[]} ctx.errors + * @param {Object} options + * @param {Object} options.manager + * @param {String} options.manager.pageTitle + * @param {Object} options.dingus + * @param {String} options.dingus.selfBaseUrl + * @returns {String} + */ +module.exports = (ctx, options) => { + const pageTitle = options.manager.pageTitle; + const footerEntries = options.manager.footerEntries; + const headElements = []; + const navLinks = []; + const mainContent = [ + errorsSection(ctx), + ]; + return th.htmlTemplate(ctx, 2, pageTitle, headElements, navLinks, mainContent, footerEntries); +}; \ No newline at end of file diff --git a/src/template/admin-login-html.js b/src/template/admin-login-html.js new file mode 100644 index 0000000..a87f930 --- /dev/null +++ b/src/template/admin-login-html.js @@ -0,0 +1,83 @@ +'use strict'; + +const th = require('./template-helper'); + + +/** + * Login form. + */ +function indieAuthSection() { + return `
+

Login

+
+
+ IndieAuth + + + +
+
+ Logging in with an IndieAuth profile will allow you to view details of any topics on this hub which are related to that profile's domain. +
+
+
+
`; +} + + +function userSection(ctx, options) { + const secure = (ctx.clientProtocol || '').toLowerCase() === 'https'; + const showUserForm = secure || !options.authenticator.secureAuthOnly; + return showUserForm ? `
+
+
+ User Account + + +
+ + +
+ +
+
+
+
` + : ''; +} + + +function errorsSection(ctx) { + return (ctx.errors && ctx.errors.length) ? `
+

Troubles

+

Problems were encountered while trying to authenticate you.

+ +
` + : ''; +} + + +/** + * Render login form for both local and profile authentication. + * @param {Object} ctx + * @param {Object} options + * @param {Object} options.manager + * @param {String} options.manager.pageTitle + * @param {Object} options.dingus + * @param {String} options.dingus.selfBaseUrl + * @returns {String} + */ +module.exports = (ctx, options) => { + const pageTitle = options.manager.pageTitle; + const footerEntries = options.manager.footerEntries; + const headElements = []; + const navLinks = []; + const mainContent = [ + errorsSection(ctx), + indieAuthSection(), + userSection(ctx, options), + ]; + return th.htmlTemplate(ctx, 2, pageTitle, headElements, navLinks, mainContent, footerEntries); +}; \ No newline at end of file diff --git a/src/template/admin-overview-html.js b/src/template/admin-overview-html.js index 3d4f62c..a8efef3 100644 --- a/src/template/admin-overview-html.js +++ b/src/template/admin-overview-html.js @@ -19,7 +19,7 @@ module.exports = (ctx, options) => { if (!ctx.topics) { ctx.topics = []; } - return th.htmlTemplate(1, pageTitle, headElements, navLinks, [ + return th.htmlTemplate(ctx, 1, pageTitle, headElements, navLinks, [ `

${ctx.topics.length ? ctx.topics.length : 'no'} topic${(ctx.topics.length === 1) ? '' : 's'}

diff --git a/src/template/admin-topic-details-html.js b/src/template/admin-topic-details-html.js index df13f21..e039189 100644 --- a/src/template/admin-topic-details-html.js +++ b/src/template/admin-topic-details-html.js @@ -25,14 +25,14 @@ module.exports = (ctx, options) => { if (!ctx.subscriptions) { ctx.subscriptions = []; } - return th.htmlTemplate(2, pageTitle, headElements, navLinks, [ + return th.htmlTemplate(ctx, 2, pageTitle, headElements, navLinks, [ `
`, th.renderTopicRowHeader(), ` `, - ...(ctx.topic && [ th.renderTopicRow(ctx.topic, ctx.subscriptions, false) ]), + ...(ctx.topic && [ th.renderTopicRow(ctx.topic, ctx.subscriptions, false) ] || []), `
`, diff --git a/src/template/index.js b/src/template/index.js index e177788..ced94bf 100644 --- a/src/template/index.js +++ b/src/template/index.js @@ -1,6 +1,8 @@ 'use strict'; module.exports = { + adminLoginHTML: require('./admin-login-html'), + adminIAHTML: require('./admin-ia-html'), adminOverviewHTML: require('./admin-overview-html'), adminTopicDetailsHTML: require('./admin-topic-details-html'), badgeSVG: require('./badge-svg'), diff --git a/src/template/root-html.js b/src/template/root-html.js index 8797564..fc655c0 100644 --- a/src/template/root-html.js +++ b/src/template/root-html.js @@ -2,6 +2,18 @@ const th = require('./template-helper'); +function hAppSection(pageTitle) { + return ` `; +} + function aboutSection() { return `

What

@@ -82,7 +94,7 @@ function usageSection(isPublicHub, hubURL) {

Publishing Updates

- To notify the Hub that a topic's content has been updated and should be distributed to subscribers, send a POST request with Form Data (application/x-www-form-urlencoded): + To notify the Hub either of a new topic to syndicate, or that a topic's content has been updated and should be distributed to subscribers, send a POST request with Form Data (application/x-www-form-urlencoded):
  • hub.mode set to publish @@ -138,12 +150,16 @@ module.exports = (ctx, options) => { const footerEntries = options.manager.footerEntries; const hubURL = options.dingus.selfBaseUrl || 'https://hub.example.com/'; const headElements = []; - const navLinks = []; + const navLinks = [{ + href: 'admin/', + text: 'Admin', + }]; const mainContent = [ aboutSection(), usageSection(isPublicHub, hubURL), contactSection(contactHTML), + hAppSection(pageTitle), ]; - return th.htmlTemplate(1, pageTitle, headElements, navLinks, mainContent, footerEntries, + return th.htmlTemplate(ctx, 0, pageTitle, headElements, navLinks, mainContent, footerEntries, ); }; \ No newline at end of file diff --git a/src/template/template-helper.js b/src/template/template-helper.js index f36829d..c633e41 100644 --- a/src/template/template-helper.js +++ b/src/template/template-helper.js @@ -61,6 +61,11 @@ const secondsToPeriod = (seconds) => { * @returns {String} */ function renderTopicRow(topic, subscribers, detailsLink = true) { + if (!topic) { + return ` + (topic not found) +`; + } return ` ${detailsLink ? '' : ''}${topic.url}${detailsLink ? '' : ''} ${subscribers.length} @@ -112,6 +117,11 @@ function renderTopicRowHeader() { * @returns {String} */ function renderSubscriptionRow(subscription) { + if (!subscription) { + return ` + (topic not found) +`; + } return ` ${subscription.callback} ${dateOrNot(subscription.created, 'Unknown')} @@ -236,7 +246,8 @@ function htmlFooter(footerEntries = []) { /** - * Render all parts of an HTML page. + * Render all parts of an HTML page. Adds user logout nav link automatically. + * @param {Object} ctx * @param {Number} pagePathLevel * @param {String} pageTitle * @param {String[]} headElements @@ -245,7 +256,20 @@ function htmlFooter(footerEntries = []) { * @param {String[]} footerEntries * @returns {String} */ -function htmlTemplate(pagePathLevel, pageTitle, headElements = [], navLinks = [], main = [], footerEntries = []) { +function htmlTemplate(ctx, pagePathLevel, pageTitle, headElements = [], navLinks = [], main = [], footerEntries = []) { + const user = (ctx && ctx.session && ctx.session.authenticatedProfile) || (ctx && ctx.session && ctx.session.authenticatedIdentifier); + if (user) { + let logoutPath; + if (pagePathLevel > 0) { + logoutPath = `${'../'.repeat(pagePathLevel - 1)}`; + } else { + logoutPath = 'admin/'; + } + navLinks.push({ + text: `Logout (${user})`, + href: `${logoutPath}logout`, + }); + } return [ htmlHead(pagePathLevel, pageTitle, headElements), htmlHeader(pageTitle, navLinks), diff --git a/static/theme.css b/static/theme.css index 3e4c4f4..4987191 100644 --- a/static/theme.css +++ b/static/theme.css @@ -11,16 +11,36 @@ body { flex-direction: column; } header {} +header nav { + margin-bottom: 1em; +} header nav ol { list-style-type: none; margin: 0; padding: 0; - border: 1px solid #000; } -header nav ol li a { - display: block; - width: 10em; +header nav ol li { + display: inline; text-align: center; + border-top: 2px solid #666; + border-bottom: 2px solid #666; + border-left: 1px solid #666; + border-right: 1px solid #666; + padding: .3em .5em .2em .5em; +} +header nav ol > li:first-child { + border-left: 2px solid #666; +} +header nav ol > li:last-child { + border-right: 2px solid #666; +} +header nav ol li:hover { + background-color: #ddd; +} +header nav ol a { + font-variant-caps: small-caps; + text-decoration: none; + font-weight: bold; } h1 { margin-top: 1em; @@ -35,6 +55,9 @@ main { flex-grow: 1; } section {} +section + section { + margin-top: 2em; +} .about {} .usage {} .topics {} @@ -45,6 +68,15 @@ section {} background-repeat: no-repeat; padding-right: 13px; } +.hidden { + display: none; +} +.h-app img { + vertical-align: middle; +} +.errors ul { + border: 1px solid red; +} table { border: 0; width: 100%; diff --git a/test/src/authenticator.js b/test/src/authenticator.js index 8c17436..47313d8 100644 --- a/test/src/authenticator.js +++ b/test/src/authenticator.js @@ -2,37 +2,51 @@ 'use strict'; const assert = require('assert'); -const sinon = require('sinon'); +const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require const Authenticator = require('../../src/authenticator'); const stubLogger = require('../stub-logger'); const stubDb = require('../stub-db'); const Errors = require('../../src/errors'); const Enum = require('../../src/enum'); +const Config = require('../../config'); const noExpectedException = 'did not receive expected exception'; describe('Authenticator', function () { let authenticator, credential, ctx, identifier, password, options; + function _authMechanismRequired(a, m) { + if (!a.authn[m]) { // eslint-disable-line security/detect-object-injection + this.skip(); + } + }; + beforeEach(function () { - options = { - authenticator: { - basicRealm: 'realm', - secureAuthOnly: true, - }, - }; + options = Config('test'); authenticator = new Authenticator(stubLogger, stubDb, options); identifier = 'username'; credential = '$argon2id$v=19$m=4096,t=3,p=1$1a6zRlX4BI4$sZGcQ72BTpDOlxUI/j3DmE1PMcu+Cs5liZ/D6kk79Ew'; ctx = {}; password = 'badPassword'; + stubDb._reset(); }); afterEach(function () { sinon.restore(); }); + it('covers no auth mechanisms', function () { + options.authenticator.authnEnabled = []; + try { + authenticator = new Authenticator(stubLogger, stubDb, options); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'no authentication mechanisms available'); + } + }); + describe('isValidBasic', function () { it('succeeds', async function () { - sinon.stub(authenticator.db, 'authenticationGet').resolves({ + _authMechanismRequired(authenticator, 'argon2'); + authenticator.db.authenticationGet.resolves({ identifier, credential, }); @@ -42,7 +56,8 @@ describe('Authenticator', function () { assert.strictEqual(ctx.authenticationId, identifier); }); it('fails', async function () { - sinon.stub(authenticator.db, 'authenticationGet').resolves({ + _authMechanismRequired(authenticator, 'argon2'); + authenticator.db.authenticationGet.resolves({ identifier, credential, }); @@ -52,14 +67,14 @@ describe('Authenticator', function () { assert.strictEqual(ctx.authenticationId, undefined); }); it('covers no entry', async function() { - sinon.stub(authenticator.db, 'authenticationGet').resolves(); + authenticator.db.authenticationGet.resolves(); const authString = `${identifier}:wrongPassword}`; const result = await authenticator.isValidBasic(authString, ctx); assert.strictEqual(result, false); assert.strictEqual(ctx.authenticationId, undefined); }); it('covers unknown password hash', async function () { - sinon.stub(authenticator.db, 'authenticationGet').resolves({ + authenticator.db.authenticationGet.resolves({ identifier, credential: '$other$kind_of_credential', }); @@ -70,6 +85,95 @@ describe('Authenticator', function () { }); }); // isValidBasic + describe('isValidIdentifierCredential', function () { + it('succeeds', async function () { + _authMechanismRequired(authenticator, 'argon2'); + authenticator.db.authenticationGet.resolves({ + identifier, + credential, + }); + const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx); + assert.strictEqual(result, true); + assert.strictEqual(ctx.authenticationId, identifier); + }); + it('fails', async function () { + _authMechanismRequired(authenticator, 'argon2'); + authenticator.db.authenticationGet.resolves({ + identifier, + credential, + }); + const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx); + assert.strictEqual(result, false); + assert.strictEqual(ctx.authenticationId, undefined); + }); + it('covers no entry', async function() { + authenticator.db.authenticationGet.resolves(); + const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx); + assert.strictEqual(result, false); + assert.strictEqual(ctx.authenticationId, undefined); + }); + it('covers unknown password hash', async function () { + authenticator.db.authenticationGet.resolves({ + identifier, + credential: '$other$kind_of_credential', + }); + const result = await authenticator.isValidIdentifierCredential(identifier, 'wrongPassword', ctx); + assert.strictEqual(result, false); + assert.strictEqual(ctx.authenticationId, undefined); + }); + it('covers PAM', async function () { + _authMechanismRequired(authenticator, 'pam'); + sinon.stub(authenticator, '_isValidPAMIdentifier').resolves(true); + authenticator.db.authenticationGet.resolves({ + identifier, + credential: '$PAM$', + }); + const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx); + assert.strictEqual(result, true); + assert.strictEqual(ctx.authenticationId, identifier); + }); + it('covers debug', async function () { + authenticator.authnEnabled = ['DEBUG_ANY']; + const result = await authenticator.isValidIdentifierCredential(identifier, password, ctx); + assert.strictEqual(result, true); + assert.strictEqual(ctx.authenticationId, identifier); + }); + }); // isValidIdentifierCredential + + describe('_isValidPAMIdentifier', function () { + beforeEach(function () { + _authMechanismRequired(authenticator, 'pam'); + sinon.stub(authenticator.authn.pam, 'pamAuthenticatePromise'); + }); + it('covers success', async function () { + authenticator.authn.pam.pamAuthenticatePromise.resolves(true); + const result = await authenticator._isValidPAMIdentifier(identifier, credential); + assert.strictEqual(result, true); + }); + it('covers failure', async function () { + _authMechanismRequired(authenticator, 'pam'); + authenticator.authn.pam.pamAuthenticatePromise.rejects(new authenticator.authn.pam.PamError()); + const result = await authenticator._isValidPAMIdentifier(identifier, credential); + assert.strictEqual(result, false); + }); + it('covers error', async function () { + _authMechanismRequired(authenticator, 'pam'); + const expected = new Error('blah'); + authenticator.authn.pam.pamAuthenticatePromise.rejects(expected); + try { + await authenticator._isValidPAMIdentifier(identifier, credential); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expected); + } + }); + it('covers forbidden', async function () { + identifier = 'root'; + const result = await authenticator._isValidPAMIdentifier(identifier, credential); + assert.strictEqual(result, false); + }); + }); // _isValidPAMIdentifier + describe('isValidAuthorization', function () { it('handles basic', async function () { const expected = true; @@ -101,6 +205,35 @@ describe('Authenticator', function () { }); }); // requestBasic + describe('isValidCookieAuth', function () { + beforeEach(function () { + sinon.stub(authenticator.mysteryBox, 'unpack'); + }); + it('covers identifier success', async function () { + authenticator.mysteryBox.unpack.resolves({ + authenticatedIdentifier: 'identifier', + }); + const result = await authenticator.isValidCookieAuth(ctx, 'WSHas=dummy'); + assert.strictEqual(result, true); + }); + it('covers profile success', async function () { + authenticator.mysteryBox.unpack.resolves({ + authenticatedProfile: 'profile', + }); + const result = await authenticator.isValidCookieAuth(ctx, 'WSHas=dummy'); + assert.strictEqual(result, true); + }); + it('covers missing cookie', async function () { + const result = await authenticator.isValidCookieAuth(ctx, 'wrongCookie'); + assert.strictEqual(result, false); + }); + it('covers bad cookie', async function () { + authenticator.mysteryBox.unpack.rejects(); + const result = await authenticator.isValidCookieAuth(ctx, 'WSHas=dummy'); + assert.strictEqual(result, false); + }); + }); // isValidCookieAuth + describe('required', function () { let req, res; beforeEach(function () { @@ -109,6 +242,7 @@ describe('Authenticator', function () { getHeader: sinon.stub(), }; res = { + end: sinon.stub(), setHeader: sinon.stub(), } }); @@ -118,6 +252,12 @@ describe('Authenticator', function () { const result = await authenticator.required(req, res, ctx); assert.strictEqual(result, true); }); + it('covers valid cookie session', async function () { + req.getHeader.returns('WSHas=sessionCookie'); + sinon.stub(authenticator, 'isValidCookieAuth').resolves(true); + const result = await authenticator.required(req, res, ctx); + assert.strictEqual(result, true); + }); it('rejects insecure connection', async function () { ctx.clientProtocol = 'http'; try { @@ -139,5 +279,66 @@ describe('Authenticator', function () { assert.strictEqual(e.statusCode, Enum.ErrorResponse.Unauthorized.statusCode); } }); + it('redirects without any auth', async function () { + await authenticator.required(req, res, ctx); + assert(res.end.called); + assert(res.setHeader.called); + }); }); // required + + describe('requiredLocal', function () { + let req, res; + beforeEach(function () { + ctx.clientProtocol = 'https'; + req = { + getHeader: sinon.stub(), + }; + res = { + end: sinon.stub(), + setHeader: sinon.stub(), + } + }); + it('succeeds', async function() { + req.getHeader.returns('auth header'); + sinon.stub(authenticator, 'isValidAuthorization').resolves(true); + const result = await authenticator.requiredLocal(req, res, ctx); + assert.strictEqual(result, true); + }); + it('covers valid cookie session', async function () { + req.getHeader.returns('WSHas=sessionCookie'); + sinon.stub(authenticator, 'isValidCookieAuth').resolves(true); + ctx.session = { + authenticatedIdentifier: identifier, + }; + const result = await authenticator.requiredLocal(req, res, ctx); + assert.strictEqual(result, true); + }); + it('rejects insecure connection', async function () { + ctx.clientProtocol = 'http'; + try { + await authenticator.requiredLocal(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof Errors.ResponseError); + assert.strictEqual(e.statusCode, Enum.ErrorResponse.Forbidden.statusCode); + } + }); + it('rejects invalid auth', async function () { + try { + req.getHeader.returns('auth header'); + sinon.stub(authenticator, 'isValidAuthorization').resolves(false); + await authenticator.requiredLocal(req, res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof Errors.ResponseError); + assert.strictEqual(e.statusCode, Enum.ErrorResponse.Unauthorized.statusCode); + } + }); + it('redirects without any auth', async function () { + await authenticator.requiredLocal(req, res, ctx); + assert(res.end.called); + assert(res.setHeader.called); + }); + }); // requiredLocal + }); // Authenticator diff --git a/test/src/manager.js b/test/src/manager.js index 89e3d65..7db6b84 100644 --- a/test/src/manager.js +++ b/test/src/manager.js @@ -150,7 +150,7 @@ describe('Manager', function () { }); // getInfo describe('getAdminOverview', function () { - it('covers', async function () { + beforeEach(function () { manager.db.topicGetAll.resolves([ { id: '56c557ce-e667-11eb-bd80-0025905f714a', @@ -172,13 +172,23 @@ describe('Manager', function () { subscribers: 12, }, ]); + }); + it('covers', async function () { + await manager.getAdminOverview(res, ctx); + assert(res.end.called); + }); + it('covers non-matching profile', async function () { + ctx.session = { + authenticatedProfile: 'https://different.example.com/profile', + }; await manager.getAdminOverview(res, ctx); + assert.deepStrictEqual(ctx.topics, []); assert(res.end.called); }); }); // getAdminOverview describe('getTopicDetails', function () { - it('covers', async function() { + beforeEach(function () { ctx.params.topicId = '56c557ce-e667-11eb-bd80-0025905f714a'; manager.db.topicGetById.resolves({ id: '56c557ce-e667-11eb-bd80-0025905f714a', @@ -214,7 +224,25 @@ describe('Manager', function () { deliveryAttemptsSinceSuccess: 0, deliveryNextAttempt: new Date(-Infinity), }]); + }); + it('covers', async function() { + await manager.getTopicDetails(res, ctx); + assert(res.end.called); + }); + it('covers non-matching profile', async function () { + ctx.session = { + authenticatedProfile: 'https://different.example.com/profile', + }; + await manager.getTopicDetails(res, ctx); + assert.strictEqual(ctx.topic, null); + assert(res.end.called); + }); + it('covers matching profile', async function () { + ctx.session = { + authenticatedProfile: 'https://example.com/profile', + }; await manager.getTopicDetails(res, ctx); + assert(ctx.topic); assert(res.end.called); }); }); // getTopicDetails diff --git a/test/src/service.js b/test/src/service.js index 7a97cf5..bb93d2e 100644 --- a/test/src/service.js +++ b/test/src/service.js @@ -20,6 +20,7 @@ describe('Service', function () { options = new Config('test'); service = new Service(stubLogger, stubDb, options); sinon.stub(service.manager); + sinon.stub(service.sessionManager); sinon.stub(service.authenticator); sinon.stub(service, 'setResponseType'); sinon.stub(service, 'serveFile'); @@ -119,7 +120,7 @@ describe('Service', function () { it('covers', async function () { service.serveFile.resolves(); await service.handlerPostAdminProcess(req, res, ctx); - assert(service.authenticator.required.called); + assert(service.authenticator.requiredLocal.called); assert(service.manager.processTasks.called); }); }); // handlerPostAdminProcess @@ -128,7 +129,7 @@ describe('Service', function () { it('covers', async function () { sinon.stub(service, 'bodyData').resolves(); await service.handlerUpdateTopic(req, res, ctx); - assert(service.authenticator.required.called); + assert(service.authenticator.requiredLocal.called); assert(service.manager.updateTopic.called); }); }); // handlerUpdateTopic @@ -137,9 +138,38 @@ describe('Service', function () { it('covers', async function () { sinon.stub(service, 'bodyData').resolves(); await service.handlerUpdateSubscription(req, res, ctx); - assert(service.authenticator.required.called); + assert(service.authenticator.requiredLocal.called); assert(service.manager.updateSubscription.called); }); }); // handlerUpdateSubscription + describe('handlerGetAdminLogin', function () { + it('covers', async function () { + await service.handlerGetAdminLogin(req, res, ctx); + assert(service.sessionManager.getAdminLogin.called); + }); + }); // handlerGetAdminLogin + + describe('handlerPostAdminLogin', function () { + it('covers', async function () { + sinon.stub(service, 'bodyData').resolves(); + await service.handlerPostAdminLogin(req, res, ctx); + assert(service.sessionManager.postAdminLogin.called); + }); + }); // handlerPostAdminLogin + + describe('handlerGetAdminLogout', function () { + it('covers', async function () { + await service.handlerGetAdminLogout(req, res, ctx); + assert(service.sessionManager.getAdminLogout.called); + }); +}); // handlerGetAdminLogout + + describe('handlerGetAdminIA', function () { + it('covers', async function () { + await service.handlerGetAdminIA(req, res, ctx); + assert(service.sessionManager.getAdminIA.called); + }); + }); // handlerGetAdminIA + }); \ No newline at end of file diff --git a/test/src/session-manager.js b/test/src/session-manager.js new file mode 100644 index 0000000..0d77ba0 --- /dev/null +++ b/test/src/session-manager.js @@ -0,0 +1,240 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments, sonarjs/no-duplicate-string, sonarjs/no-identical-functions */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require + +const SessionManager = require('../../src/session-manager'); +const Config = require('../../config'); +const stubLogger = require('../stub-logger'); + +describe('SessionManager', function () { + let manager, options, stubAuthenticator; + let res, ctx; + + beforeEach(function () { + options = new Config('test'); + res = { + end: sinon.stub(), + setHeader: sinon.stub(), + }; + ctx = { + cookie: '', + params: {}, + queryParams: {}, + parsedBody: {}, + }; + stubAuthenticator = { + isValidIdentifierCredential: sinon.stub(), + }; + manager = new SessionManager(stubLogger, stubAuthenticator, options); + sinon.stub(manager.indieAuthCommunication); + stubLogger._reset(); + }); + afterEach(function () { + sinon.restore(); + }); + + describe('_sessionCookieSet', function () { + let session, maxAge; + beforeEach(function () { + session = {}; + maxAge = 86400; + }); + it('covers', async function () { + await manager._sessionCookieSet(res, session, maxAge); + assert(res.setHeader.called); + }); + it('covers reset', async function () { + session = undefined; + maxAge = 0; + await manager._sessionCookieSet(res, session, maxAge); + assert(res.setHeader.called); + }); + }); // _sessionCookieSet + + describe('getAdminLogin', function () { + it('covers', async function () { + await manager.getAdminLogin(res, ctx); + }); + }); // getAdminLogin + + describe('postAdminLogin', function () { + it('covers valid local', async function () { + ctx.parsedBody.identifier = 'user'; + ctx.parsedBody.credential = 'password'; + manager.authenticator.isValidIdentifierCredential.resolves(true); + await manager.postAdminLogin(res, ctx); + assert.strictEqual(res.statusCode, 302); + }); + it('covers invalid local', async function () { + ctx.parsedBody.identifier = 'user'; + ctx.parsedBody.credential = 'password'; + manager.authenticator.isValidIdentifierCredential.resolves(false); + await manager.postAdminLogin(res, ctx); + assert(!res.setHeader.called); + }); + it('covers valid profile', async function () { + ctx.parsedBody.me = 'https://example.com/profile'; + manager.indieAuthCommunication.fetchProfile.resolves({ + authorizationEndpoint: 'https://example.com/auth', + }); + await manager.postAdminLogin(res, ctx); + assert.strictEqual(res.statusCode, 302); + }); + it('covers invalid profile', async function () { + ctx.parsedBody.me = 'not a profile'; + manager.indieAuthCommunication.fetchProfile.resolves(); + await manager.postAdminLogin(res, ctx); + assert(!res.setHeader.called); + }); + it('covers invalid profile response', async function () { + ctx.parsedBody.me = 'https://example.com/profile'; + manager.indieAuthCommunication.fetchProfile.resolves(); + await manager.postAdminLogin(res, ctx); + assert(!res.setHeader.called); + }); + it('covers invalid profile response endpoint', async function () { + ctx.parsedBody.me = 'https://example.com/profile'; + manager.indieAuthCommunication.fetchProfile.resolves({ + authorizationEndpoint: 'not an auth endpoint', + }); + await manager.postAdminLogin(res, ctx); + assert(!res.setHeader.called); + }); + }); // postAdminLogin + + describe('getAdminLogout', function () { + it('covers', async function () { + await manager.getAdminLogout(res, ctx); + }); + }); // getAdminLogout + + describe('getAdminIA', function () { + let state, me, authorizationEndpoint; + beforeEach(function () { + state = '4ea7e936-3427-11ec-9f4b-0025905f714a'; + me = 'https://example.com/profile'; + authorizationEndpoint = 'https://example.com/auth' + ctx.cookie = 'WSHas=sessionCookie'; + manager.indieAuthCommunication.redeemProfileCode.resolves({ + me, + }); + manager.indieAuthCommunication.fetchProfile.resolves({ + authorizationEndpoint, + }); + sinon.stub(manager.mysteryBox, 'unpack').resolves({ + authorizationEndpoint, + state, + me, + }); + }); + it('covers valid', async function () { + ctx.queryParams['state'] = state; + ctx.queryParams['code'] = 'codeCodeCode'; + + await manager.getAdminIA(res, ctx); + + assert.strictEqual(res.statusCode, 302); + }); + it('covers missing cookie', async function () { + delete ctx.cookie; + + await manager.getAdminIA(res, ctx); + + assert(ctx.errors.length); + }); + it('covers invalid cookie', async function () { + manager.mysteryBox.unpack.restore(); + sinon.stub(manager.mysteryBox, 'unpack').rejects(); + + await manager.getAdminIA(res, ctx); + + assert(ctx.errors.length); + }); + it('covers mis-matched state', async function () { + ctx.queryParams['state'] = 'incorrect-state'; + ctx.queryParams['code'] = 'codeCodeCode'; + + await manager.getAdminIA(res, ctx); + + assert(ctx.errors.length); + }); + it('relays auth endpoint errors', async function () { + ctx.queryParams['state'] = state; + ctx.queryParams['code'] = 'codeCodeCode'; + ctx.queryParams['error'] = 'error_code'; + ctx.queryParams['error_description'] = 'something went wrong'; + + await manager.getAdminIA(res, ctx); + + assert(ctx.errors.length); + }); + it('covers invalid restored session', async function () { + manager.mysteryBox.unpack.restore(); + sinon.stub(manager.mysteryBox, 'unpack').resolves({ + authorizationEndpoint: 'not a url', + state, + me, + }); + ctx.queryParams['state'] = state; + ctx.queryParams['code'] = 'codeCodeCode'; + + await manager.getAdminIA(res, ctx); + + assert(ctx.errors.length); + }); + it('covers empty profile redemption response', async function () { + ctx.queryParams['state'] = state; + ctx.queryParams['code'] = 'codeCodeCode'; + manager.indieAuthCommunication.redeemProfileCode.restore(); + sinon.stub(manager.indieAuthCommunication, 'redeemProfileCode').resolves(); + + await manager.getAdminIA(res, ctx); + + assert(ctx.errors.length); + }); + it('covers missing profile in redemption response', async function () { + ctx.queryParams['state'] = state; + ctx.queryParams['code'] = 'codeCodeCode'; + manager.indieAuthCommunication.redeemProfileCode.restore(); + sinon.stub(manager.indieAuthCommunication, 'redeemProfileCode').resolves({ + }); + + await manager.getAdminIA(res, ctx); + + assert(ctx.errors.length); + }); + it('covers different canonical profile response', async function () { + ctx.queryParams['state'] = state; + ctx.queryParams['code'] = 'codeCodeCode'; + manager.indieAuthCommunication.redeemProfileCode.restore(); + sinon.stub(manager.indieAuthCommunication, 'redeemProfileCode').resolves({ + me: 'https://different.example.com/profile', + }); + + await manager.getAdminIA(res, ctx); + + assert.strictEqual(res.statusCode, 302); + }); + it('covers different canonical profile response mis-matched endpoint', async function () { + ctx.queryParams['state'] = state; + ctx.queryParams['code'] = 'codeCodeCode'; + manager.indieAuthCommunication.redeemProfileCode.restore(); + sinon.stub(manager.indieAuthCommunication, 'redeemProfileCode').resolves({ + me: 'https://different.example.com/profile', + }); + manager.indieAuthCommunication.fetchProfile.restore(); + sinon.stub(manager.indieAuthCommunication, 'fetchProfile').resolves({ + authorizationEndpoint: 'https://elsewhere.example.com/auth', + }); + + await manager.getAdminIA(res, ctx); + + assert(ctx.errors.length); + }); + }); // getAdminIA + +}); // SessionManager \ No newline at end of file diff --git a/test/src/template/admin-ia-html.js b/test/src/template/admin-ia-html.js new file mode 100644 index 0000000..d909389 --- /dev/null +++ b/test/src/template/admin-ia-html.js @@ -0,0 +1,25 @@ +/* eslint-env mocha */ +'use strict'; + +const assert = require('assert'); +const template = require('../../../src/template/admin-ia-html'); +const Config = require('../../../config'); +const config = new Config('test'); + +describe('Admin Login HTML Template', function () { + let ctx; + + beforeEach(function () { + ctx = {}; + }); + + it('covers', function () { + ctx.errors = ['bad']; + const result = template(ctx, config); + assert(result); + }); + it('covers empty', function () { + const result = template(ctx, config); + assert(result); + }); +}); diff --git a/test/src/template/admin-login-html.js b/test/src/template/admin-login-html.js new file mode 100644 index 0000000..661efd9 --- /dev/null +++ b/test/src/template/admin-login-html.js @@ -0,0 +1,26 @@ +/* eslint-env mocha */ +'use strict'; + +const assert = require('assert'); +const template = require('../../../src/template/admin-login-html'); +const Config = require('../../../config'); +const config = new Config('test'); + +describe('Admin Login HTML Template', function () { + let ctx; + + beforeEach(function () { + ctx = {}; + }); + + it('covers', function () { + ctx.errors = ['bad']; + ctx.clientProtocol = 'https'; + const result = template(ctx, config); + assert(result); + }); + it('covers empty', function () { + const result = template(ctx, config); + assert(result); + }); +}); diff --git a/test/src/template/admin-topic-details-html.js b/test/src/template/admin-topic-details-html.js index 52a8ed8..4b8c0dd 100644 --- a/test/src/template/admin-topic-details-html.js +++ b/test/src/template/admin-topic-details-html.js @@ -22,6 +22,12 @@ describe('Admin Topic Details HTML Template', function () { const result = template(ctx, config); assert(result); }); + it('covers null topic', function () { + ctx.topic = null; + ctx.subscriptions = null; + const result = template(ctx, config); + assert(result); + }); it('covers missing subscriptions', function () { delete ctx.subscriptions; const result = template(ctx, config); @@ -32,5 +38,4 @@ describe('Admin Topic Details HTML Template', function () { const result = template(ctx, config); assert(result); }); - }); diff --git a/test/src/template/template-helper.js b/test/src/template/template-helper.js index 4ddf360..3ad7f83 100644 --- a/test/src/template/template-helper.js +++ b/test/src/template/template-helper.js @@ -75,6 +75,12 @@ describe('Template Helper', function () { const result = th.renderTopicRow(topic, subscribers); assert(result); }); + it('covers empty', function () { + topic = null; + subscribers = null; + const result = th.renderTopicRow(topic, subscribers); + assert(result); + }); it('covers no link', function () { subscribers = [{}, {}]; const result = th.renderTopicRow(topic, subscribers, false); @@ -103,6 +109,10 @@ describe('Template Helper', function () { const result = th.renderSubscriptionRow(subscription); assert(result); }); + it('covers empty', function () { + const result = th.renderSubscriptionRow(); + assert(result); + }); }); // renderSubscriptionRow describe('renderSubscriptionRowHeader', function () { @@ -179,6 +189,10 @@ describe('Template Helper', function () { describe('htmlFooter', function () { it('covers', function () { + const result = th.htmlFooter(['foo', 'bar']); + assert(result); + }); + it('covers default', function () { const result = th.htmlFooter(); assert(result); }); @@ -187,6 +201,7 @@ describe('Template Helper', function () { describe('htmlTemplate', function () { let pagePathLevel, pageTitle, headElements, navLinks, main; beforeEach(function () { + ctx = {}; pagePathLevel = 1; pageTitle = 'title'; headElements = []; @@ -194,11 +209,26 @@ describe('Template Helper', function () { main = []; }); it('covers', function () { - const result = th.htmlTemplate(pagePathLevel, pageTitle, headElements, navLinks, main); + const result = th.htmlTemplate(ctx, pagePathLevel, pageTitle, headElements, navLinks, main); assert(result); }); it('covers defaults', function () { - const result = th.htmlTemplate(pagePathLevel, pageTitle); + const result = th.htmlTemplate(ctx, pagePathLevel, pageTitle); + assert(result); + }); + it('covers user', function () { + ctx.session = { + authenticatedProfile: 'user', + }; + const result = th.htmlTemplate(ctx, pagePathLevel, pageTitle); + assert(result); + }); + it('covers user at root path', function () { + ctx.session = { + authenticatedIdentifier: 'user', + }; + pagePathLevel = 0; + const result = th.htmlTemplate(ctx, pagePathLevel, pageTitle); assert(result); }); }); // htmlTemplate