X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Fauthenticator.js;h=c69ada91c6ed6a5234942b1e6ae8eb92a420d7e6;hb=HEAD;hp=36c1574b507c223fe380a35ab6f0eb991de085e4;hpb=54ca04e330d14a12344ddc2b161c1d9b55bfd7d7;p=squeep-authentication-module diff --git a/lib/authenticator.js b/lib/authenticator.js index 36c1574..3df0b9d 100644 --- a/lib/authenticator.js +++ b/lib/authenticator.js @@ -16,43 +16,53 @@ const _fileScope = common.fileScope(__filename); * Interacts with the authentication database interface. */ +/** + * @typedef {import('node:http')} http + */ + class Authenticator { /** - * @typedef {Object} AuthInfo - * @property {String} identifier - * @property {String} credential - * @property {String=} otpKey + * @typedef {object} AuthInfo + * @property {string} identifier identifier + * @property {string} credential hashed credential + * @property {string=} otpKey optional otp key */ /** * @callback DBContextExec - * @param {Object} dbCtx + * @param {object} dbCtx * @returns {Promise} */ /** - * @typedef {Object} AuthDBInterface - * @property {(DBContextExec) => Promise} context - * @property {(dbCtx: any, identifier: String) => Promise } authenticationGet - * @property {(dbCtx: any, identifier: String) => Promise} authenticationSuccess - * @property {(dbCtx: any, identifier: String, credential: String, otpKey: String=) => Promise} authenticationUpsert - * @property {(dbCtx: any, identifier: String, otpKey: String) => Promise} authenticationUpdateOTPKey - * @property {(dbCtx: any, identifier: String, credential: AuthInfo) => Promise} authenticationUpdateCredential + * @typedef {object} AuthDBInterface + * @property {(DBContextExec) => Promise} context db context runner + * @property {(dbCtx: any, identifier: string) => Promise} authenticationGet get an authentication entry + * @property {(dbCtx: any, identifier: string) => Promise} authenticationSuccess store a successful authentication event + * @property {(dbCtx: any, identifier: string, credential: string, otpKey: string=) => Promise} authenticationUpsert update an authentication entry + * @property {(dbCtx: any, identifier: string, otpKey: string) => Promise} authenticationUpdateOTPKey update otp key + * @property {(dbCtx: any, identifier: string, credential: AuthInfo) => Promise} authenticationUpdateCredential update credential */ /** - * @param {Console} logger - * @param {AuthDBInterface} db - * @param {Object} options - * @param {String|String[]} options.encryptionSecret - * @param {Object} options.authenticator - * @param {Boolean} options.authenticator.secureAuthOnly - * @param {String[]} options.authenticator.forbiddenPAMIdentifiers - * @param {String[]} options.authenticator.authnEnabled in order of preference for storing new credentials - * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds - * @param {String[]=} options.authenticator.loginBlurb - * @param {String[]=} options.authenticator.indieAuthBlurb - * @param {String[]=} options.authenticator.userBlurb - * @param {String[]=} options.authenticator.otpBlurb - * @param {String=} options.dingus - * @param {String=} options.dingus.proxyPrefix + * @typedef {object} ConsoleLike + * @property {Function} debug log debug + * @property {Function} error log error + * @property {Function} info log info + */ + /** + * @param {ConsoleLike} logger logger instance + * @param {AuthDBInterface} db db instance + * @param {object} options options + * @param {string | string[]} options.encryptionSecret encryption secret + * @param {object} options.authenticator authenticator options + * @param {boolean} options.authenticator.secureAuthOnly disable auth over non-https + * @param {string[]} options.authenticator.forbiddenPAMIdentifiers reject these identifiers for PAM auth + * @param {string[]} options.authenticator.authnEnabled in order of preference for storing new credentials + * @param {number=} options.authenticator.inactiveSessionLifespanSeconds session timeout + * @param {string[]=} options.authenticator.loginBlurb text for login page + * @param {string[]=} options.authenticator.indieAuthBlurb text for indieauth login section + * @param {string[]=} options.authenticator.userBlurb text for local user login section + * @param {string[]=} options.authenticator.otpBlurb text for otp entry + * @param {string=} options.dingus dingus options + * @param {string=} options.dingus.proxyPrefix base url prefix */ constructor(logger, db, options) { this.logger = logger; @@ -69,11 +79,11 @@ class Authenticator { }; try { this.authn.argon2 = require('argon2'); - } catch (e) { /**/ } + } catch (e) { /**/ } // eslint-disable-line no-unused-vars try { this.authn.pam = require('node-linux-pam'); this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers ?? ['root']; - } catch (e) { /**/ } + } catch (e) { /**/ } // eslint-disable-line no-unused-vars // Track which authn methods we can change credentials et cetera. const authnUpdatable = ['plain', 'argon2']; @@ -107,10 +117,10 @@ class Authenticator { /** * Populate the authentication database with a new identifier, the * secured credential, and optionally an OTP key. - * @param {*} dbCtx - * @param {String} identifier - * @param {String} credential plaintext - * @param {String=} otpKey + * @param {*} dbCtx db context + * @param {string} identifier identifier + * @param {string} credential plaintext + * @param {string=} otpKey otp key * @returns {Promise} */ async createIdentifier(dbCtx, identifier, credential, otpKey = null) { @@ -128,9 +138,9 @@ class Authenticator { /** * Update the authentication database with a new secured credential * for an indentifier. - * @param {*} dbCtx - * @param {*} identifier - * @param {*} credential plaintext + * @param {*} dbCtx dbCtx + * @param {string} identifier identifier + * @param {string} credential plaintext * @returns {Promise} */ async updateCredential(dbCtx, identifier, credential) { @@ -148,9 +158,9 @@ class Authenticator { /** * Encode a plaintext credential in the preferred way to store in database. - * @param {String} credential - * @param {String=} authn - * @returns {Promise} + * @param {string} credential plaintext + * @param {string=} authn authentication mechanism + * @returns {Promise} encoded credential */ async _secureCredential(credential, authn = this.authnPreferred) { const _scope = _fileScope('_secureCredential'); @@ -175,8 +185,8 @@ class Authenticator { /** * Checks a supplied credential against supplied data. * @param {AuthInfo} authData from database - * @param {String} credential plaintext - * @returns {Promise} + * @param {string} credential plaintext + * @returns {Promise} is valid */ async _validateAuthDataCredential(authData, credential) { const _scope = _fileScope('_validateAuthDataCredential'); @@ -204,9 +214,9 @@ class Authenticator { /** * Check argon2. - * @param {AuthInfo} authData - * @param {String} credential - * @returns {Promise} + * @param {AuthInfo} authData auth entry + * @param {string} credential to check + * @returns {Promise} is valid */ async _isValidArgon2Identifier(authData, credential) { return await this.authn.argon2.verify(authData.credential, credential); @@ -215,9 +225,9 @@ class Authenticator { /** * Check plaintext. - * @param {AuthInfo} authData - * @param {String} credential - * @returns {Promise} + * @param {AuthInfo} authData auth entry + * @param {string} credential to check + * @returns {Promise} is valid */ static _isValidPlainIdentifier(authData, credential) { return authData.credential.substring('$plain$'.length) === credential; @@ -226,9 +236,9 @@ class Authenticator { /** * Check system PAM. - * @param {AuthInfo} authData - * @param {String} credential - * @returns {Promise} + * @param {AuthInfo} authData auth entry + * @param {string} credential to check + * @returns {Promise} is valid */ async _isValidPAMIdentifier(authData, credential) { const _scope = _fileScope('_isValidPAMIdentifier'); @@ -254,10 +264,10 @@ class Authenticator { * Check local auth entries. * Sets ctx.authenticatedId if valid. * Sets ctx.otpKey if account has otpKey. - * @param {String} identifier - * @param {String} credential - * @param {Object} ctx - * @returns {Promise} + * @param {string} identifier identifier + * @param {string} credential to check + * @param {object} ctx context + * @returns {Promise} is valid */ async isValidIdentifierCredential(identifier, credential, ctx) { const _scope = _fileScope('isValidIdentifierCredential'); @@ -292,13 +302,16 @@ class Authenticator { /** - * - * @param {OTPState} state - * @param {String} state.key - * @param {Number} state.attempt - * @param {Number} state.epochMs - * @param {String} otp - * @returns {String} Enum.OTPResult + * @typedef {object} OTPState + * @property {string} key otp key + * @property {number} attempt count of attempts + * @property {number} epochMs when entry was initiated + */ + /** + * Validate if an entered otp token matches the key. + * @param {OTPState} state otp state + * @param {string} otp to check + * @returns {Enum.OTPResult} result */ checkOTP(state, otp) { const totp = new this.TOTP({ @@ -319,16 +332,34 @@ class Authenticator { } + /** + * Update the authentication database with a new otp key. + * @param {*} dbCtx db context + * @param {string} identifier identifier + * @param {string=} otpKey otp key + */ + async updateOTPKey(dbCtx, identifier, otpKey) { + const _scope = _fileScope('updateOTPKey'); + try { + await this.db.authenticationUpdateOTPKey(dbCtx, identifier, otpKey); + this.logger.info(_scope, 'otp key updated'); + } catch (e) { + this.logger.error(_scope, 'failed', { error: e, identifier }); + } + } + + /** * Check for valid Basic auth, updates ctx with identifier if valid. - * @param {String} credentials - * @param {Object} ctx - * @returns {Promise} + * @param {string} authValue basic auth value (base64) + * @param {object} ctx context + * @returns {Promise} is valid */ - async isValidBasic(credentials, ctx) { + async isValidBasic(authValue, ctx) { const _scope = _fileScope('isValidBasic'); this.logger.debug(_scope, 'called', { ctx }); + const credentials = Buffer.from(authValue, 'base64').toString('utf-8'); const [identifier, credential] = common.splitFirst(credentials, ':', ''); return this.isValidIdentifierCredential(identifier, credential, ctx); @@ -337,9 +368,9 @@ class Authenticator { /** * Determine which Authorization header is available, and if it is valid. - * @param {String} authorizationHeader - * @param {Object} ctx - * @returns {Promise} + * @param {string} authorizationHeader request header + * @param {object} ctx context + * @returns {Promise} is valid */ async isValidAuthorization(authorizationHeader, ctx) { const _scope = _fileScope('isValidAuthorization'); @@ -349,8 +380,7 @@ class Authenticator { // eslint-disable-next-line sonarjs/no-small-switch switch (authMethod.toLowerCase()) { case 'basic': { - const credentials = Buffer.from(authString, 'base64').toString('utf-8'); - return this.isValidBasic(credentials, ctx); + return this.isValidBasic(authString, ctx); } default: @@ -362,7 +392,7 @@ class Authenticator { /** * Send a response requesting basic auth. - * @param {http.ServerResponse} res + * @param {http.ServerResponse} res response */ requestBasic(res) { res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`); @@ -375,9 +405,9 @@ class Authenticator { * authenticated user. * Restores ctx.session from cookie data, sets ctx.authenticationId to * identifier or profile for session. - * @param {Object} ctx - * @param {Object} ctx.cookie - * @returns {Promise} + * @param {object} ctx context + * @param {object} ctx.cookie cookies object + * @returns {Promise} is valid */ async isValidCookieAuth(ctx) { const _scope = _fileScope('isValidCookieAuth'); @@ -416,13 +446,13 @@ class Authenticator { * @see sessionRequiredLocal * @see sessionOptional * @see sessionOptionalLocal - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx - * @param {String} loginPath - * @param {Boolean} required redirect to login url if no valid session - * @param {Boolean} profilesAllowed if true, an indieauth session is valid, otherwise only identifier/credential - * @returns {Promise} + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @param {string} loginPath url path to redirect to when login is required + * @param {boolean} required redirect to login url if no valid session + * @param {boolean} profilesAllowed if true, an indieauth session is valid, otherwise only identifier/credential + * @returns {Promise} is valid session */ async sessionCheck(req, res, ctx, loginPath, required = true, profilesAllowed = true) { const _scope = _fileScope('sessionCheck'); @@ -474,11 +504,11 @@ class Authenticator { /** * Requires a valid session with a local identifier. Redirects to loginPath if not. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx - * @param {String} loginPath - * @returns {Promise} + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @param {string} loginPath url path to redirect to when login is needed + * @returns {Promise} is valid session */ async sessionRequiredLocal(req, res, ctx, loginPath) { return this.sessionCheck(req, res, ctx, loginPath, true, false); @@ -487,11 +517,11 @@ class Authenticator { /** * Requires a valid session with either a local identifier or a profile. Redirects to loginPath if not. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx - * @param {String} loginPath - * @returns {Promise} + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @param {string} loginPath url path to redirect to when login is needed + * @returns {Promise} is valid session */ async sessionRequired(req, res, ctx, loginPath) { return this.sessionCheck(req, res, ctx, loginPath); @@ -500,11 +530,10 @@ class Authenticator { /** * Check for a valid session with a local identifier, but do nothing if not. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx - * @param {String} loginPath - * @returns {Promise} + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @returns {Promise} is valid session */ async sessionOptionalLocal(req, res, ctx) { return this.sessionCheck(req, res, ctx, undefined, false, false); @@ -513,11 +542,10 @@ class Authenticator { /** * Check for a valid session with either a local identifier or a profile, but do nothing if not. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx - * @param {String} loginPath - * @returns {Promise} + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @returns {Promise} is valid session */ async sessionOptional(req, res, ctx) { return this.sessionCheck(req, res, ctx, undefined, false); @@ -529,27 +557,32 @@ class Authenticator { * Check for valid local identifier in Authorization header; * optionally fall back to session cookie if no header provided. * Prompts for Basic auth if not valid. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx - * @param {Boolean} sessionAlsoValid - * @returns {Promise} is valid */ async apiRequiredLocal(req, res, ctx, sessionAlsoValid = true) { const _scope = _fileScope('apiRequiredLocal'); this.logger.debug(_scope, 'called', { ctx, sessionAlsoValid }); - // If a Authorization header was provided, never consider session as a fallback. - const authorizationHeader = req.getHeader(Enum.Header.Authorization); - if (authorizationHeader) { - if (await this.isValidAuthorization(authorizationHeader, ctx)) { - this.logger.debug(_scope, 'valid authorization', { ctx, sessionAlsoValid }); + try { + // If a Authorization header was provided, never consider session as a fallback. + const authorizationHeader = req.getHeader(Enum.Header.Authorization); + if (authorizationHeader) { + if (await this.isValidAuthorization(authorizationHeader, ctx)) { + this.logger.debug(_scope, 'valid authorization', { ctx, sessionAlsoValid }); + return true; + } + } else if (sessionAlsoValid + && await this.sessionCheck(req, res, ctx, undefined, false, false)) { + this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid }); return true; } - } else if (sessionAlsoValid - && await this.sessionCheck(req, res, ctx, undefined, false, false)) { - this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid }); - return true; + } catch (e) { + this.logger.error(_scope, 'failed', { error: e }); + throw e; } this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid });