X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Fauthenticator.js;h=da6b7180f4c4e8019d6f2bed82063d4ea057ba71;hb=2ca511865b0caf3108819cfd6ee775124ea70dff;hp=15089a020f5a977f09239537e210c40c6ca61173;hpb=dd173e6b450cbba8100883514610c9fde83d050a;p=squeep-authentication-module diff --git a/lib/authenticator.js b/lib/authenticator.js index 15089a0..da6b718 100644 --- a/lib/authenticator.js +++ b/lib/authenticator.js @@ -4,11 +4,18 @@ const common = require('./common'); const Enum = require('./enum'); const Errors = require('./errors'); const { MysteryBox } = require('@squeep/mystery-box'); +const { TOTP } = require('@squeep/totp'); const { name: packageName } = require('../package'); const _fileScope = common.fileScope(__filename); class Authenticator { + /** + * @typedef {Object} AuthInfo + * @property {String} identifier + * @property {String} credentials + * @property {String=} otpKey + */ /** * @param {Console} logger * @param {*} db @@ -20,6 +27,7 @@ class Authenticator { * @param {Boolean} options.authenticator.secureAuthOnly * @param {String[]} options.authenticator.forbiddenPAMIdentifiers * @param {String[]} options.authenticator.authnEnabled + * @param {Number=} options.authenticator.inactiveSessionLifespanSeconds * @param {String[]=} options.authenticator.loginBlurb * @param {String[]=} options.authenticator.indieAuthBlurb * @param {String[]=} options.authenticator.userBlurb @@ -50,37 +58,46 @@ class Authenticator { throw new Error('no authentication mechanisms available'); } - this.mysteryBox = new MysteryBox(logger, options); + this.mysteryBox = new MysteryBox(options); + this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name))); + + this.cookieLifespan = options.authenticator.inactiveSessionLifespanSeconds || 60 * 60 * 24 * 32; + + this.TOTP = TOTP; // For ease of test stubbing. } /** * Check local auth entries. * Sets ctx.authenticatedId if valid. + * Sets ctx.otpNeeded if account has otpKey. * @param {String} identifier * @param {String} credential * @param {Object} ctx + * @returns {Promise} */ async isValidIdentifierCredential(identifier, credential, ctx) { const _scope = _fileScope('isValidIdentifierCredential'); - this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat(credential.length), ctx }); + this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat((credential || '').length), ctx }); let isValid = false; + if (typeof credential === 'undefined') { + return isValid; + } + await this.db.context(async (dbCtx) => { const authData = await this.db.authenticationGet(dbCtx, identifier); if (!authData) { 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 { - 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 }); - } + this.logger.error(_scope, 'failed, unknown or unsupported type of stored credential', { identifier, ctx }); } if (this.authnEnabled.includes('DEBUG_ANY')) { @@ -89,6 +106,7 @@ class Authenticator { if (isValid) { ctx.authenticationId = identifier; + ctx.otpNeeded = !!authData?.otpKey; await this.db.authenticationSuccess(dbCtx, identifier); } }); // dbCtx @@ -101,7 +119,7 @@ class Authenticator { * Check system PAM. * @param {String} identifier * @param {String} credential - * @returns {Boolean} + * @returns {Promise} */ async _isValidPAMIdentifier(identifier, credential) { const _scope = _fileScope('_isValidPAMIdentifier'); @@ -122,11 +140,38 @@ class Authenticator { } + /** + * + * @param {Object} state + * @param {String} state.key + * @param {Number} state.attempt + * @param {Number} state.epochMs + * @param {String} otp + * @returns {String} Enum.OTPResult + */ + checkOTP(state, otp) { + const totp = new this.TOTP({ + ...this.options?.authenticator?.otpOptions, + ...state, + }); + const isValid = totp.validate(otp); + if (isValid) { + return Enum.OTPResult.Valid; + } + const otpAttemptsRemain = state.attempt <= (this.options?.authenticator?.otpAttemptsAllowed ?? 3); + const otpWithinWindow = state.epochMs + (this.options?.authenticator?.otpWindowMs ?? 600000) > Date.now(); + if (otpAttemptsRemain && otpWithinWindow) { + return Enum.OTPResult.InvalidSoftFail; + } + return Enum.OTPResult.InvalidHardFail; + } + + /** * Check for valid Basic auth, updates ctx with identifier if valid. * @param {String} credentials * @param {Object} ctx - * @returns {Boolean} + * @returns {Promise} */ async isValidBasic(credentials, ctx) { const _scope = _fileScope('isValidBasic'); @@ -142,6 +187,7 @@ class Authenticator { * Determine which Authorization header is available, and if it is valid. * @param {String} authorizationHeader * @param {Object} ctx + * @returns {Promise} */ async isValidAuthorization(authorizationHeader, ctx) { const _scope = _fileScope('isValidAuthorization'); @@ -181,7 +227,7 @@ class Authenticator { (cookieHeader || '').split(/; */).forEach((field) => { const [ name, value ] = common.splitFirst(field, '=', null).map((x) => x && decodeURIComponent(x.trim())); if (name && !(name in cookie)) { - if (value && value.startsWith('"') && value.endsWith('"')) { + if (value?.startsWith('"') && value.endsWith('"')) { cookie[name] = value.slice(1, -1); // eslint-disable-line security/detect-object-injection } else { cookie[name] = value; // eslint-disable-line security/detect-object-injection @@ -259,6 +305,18 @@ class Authenticator { && (ctx.session.authenticatedIdentifier || (profilesAllowed && ctx.session.authenticatedProfile))) { this.logger.debug(_scope, 'valid session cookie', { ctx }); + // Refresh timeout on valid session. + const cookieParts = [ + sessionCookie, + 'HttpOnly', + `Max-Age=${this.cookieLifespan}`, + 'SameSite=Lax', + `Path=${this.options.dingus.proxyPrefix}/`, + ]; + if (this.secureAuthOnly) { + cookieParts.push('Secure'); + } + res.setHeader(Enum.Header.SetCookie, cookieParts.join('; ')); return true; } @@ -268,6 +326,7 @@ class Authenticator { `${Enum.SessionCookie}=""`, 'HttpOnly', 'Max-Age=0', + 'SameSite=Lax', `Path=${this.options.dingus.proxyPrefix}/`, ]; if (this.options.authenticator.secureAuthOnly) { @@ -335,6 +394,38 @@ class Authenticator { return this.sessionCheck(req, res, ctx, undefined, false); } + + /** + * Require auth for an API endpoint. + * 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 + */ + 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 }); + return true; + } + } else if (sessionAlsoValid + && await this.sessionCheck(req, res, ctx, undefined, false, false)) { + this.logger.debug(_scope, 'valid session', { ctx, sessionAlsoValid }); + return true; + } + + this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid }); + this.requestBasic(res); + } + } -module.exports = Authenticator; \ No newline at end of file +module.exports = Authenticator;