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);
+/**
+ * Wrangles the fiddly bits of authentication.
+ * Handles checking of credentials and otp codes, creating and validating
+ * cookie-based sessions, Basic auth.
+ * Interacts with the authentication database interface.
+ */
+
class Authenticator {
+ /**
+ * @typedef {Object} AuthInfo
+ * @property {String} identifier
+ * @property {String} credential
+ * @property {String=} otpKey
+ */
+ /**
+ * @callback DBContextExec
+ * @param {Object} dbCtx
+ * @returns {Promise<any>}
+ */
+ /**
+ * @typedef {Object} AuthDBInterface
+ * @property {(DBContextExec) => Promise<any>} context
+ * @property {(dbCtx: any, identifier: String) => Promise<AuthInfo> } authenticationGet
+ * @property {(dbCtx: any, identifier: String) => Promise<void>} authenticationSuccess
+ * @property {(dbCtx: any, identifier: String, credential: String, otpKey: String=) => Promise<void>} authenticationUpsert
+ * @property {(dbCtx: any, identifier: String, otpKey: String) => Promise<void>} authenticationUpdateOTPKey
+ * @property {(dbCtx: any, identifier: String, credential: AuthInfo) => Promise<void>} authenticationUpdateCredential
+ */
/**
* @param {Console} logger
- * @param {*} db
- * @param {(dbCtx: any, identifier: String) => Promise<AuthInfo> } db.authenticationGet
- * @param {(dbCtx: any, identifier: String) => Promise<void>} db.authenticationSuccess
- * @param {((dbCtx: any) => Promise<any>) => Promise<void>} db.context
+ * @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
+ * @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
*/
constructor(logger, db, options) {
this.logger = logger;
this.db = db;
this.options = options;
this.basicRealm = options.authenticator.basicRealm || packageName;
- this.secureAuthOnly = options.authenticator.secureAuthOnly;
+ this.secureAuthOnly = options.authenticator.secureAuthOnly ?? true;
+ this.proxyPrefix = options.dingus?.proxyPrefix ?? '';
+ // First construct map of all available code-supported auth mechanisms.
this.authn = {
- DEBUG_ANY: {},
indieAuth: {},
+ plain: {},
};
try {
this.authn.argon2 = require('argon2');
} catch (e) { /**/ }
try {
this.authn.pam = require('node-linux-pam');
- this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers;
+ this.forbiddenPAMIdentifiers = options.authenticator.forbiddenPAMIdentifiers ?? ['root'];
} 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 });
+ // Track which authn methods we can change credentials et cetera.
+ const authnUpdatable = ['plain', 'argon2'];
+
+ // Filter configured mechanisms from available, note the first as which to use for setting new credentials.
+ this.authnEnabled = new Set();
+ this.authnPreferred = undefined; // For updating credentials
+ options.authenticator.authnEnabled.forEach((authn) => {
+ if (authn in this.authn) {
+ this.authnEnabled.add(authn);
+ if (!this.authnPreferred && authnUpdatable.includes(authn)) {
+ this.authnPreferred = authn;
+ }
+ }
+ });
+ this.logger.debug(_fileScope('constructor'), 'available mechanisms', { authn: Array.from(this.authnEnabled), preferred: this.authnPreferred });
- if (this.authnEnabled.length === 0) {
+ if (this.authnEnabled.size === 0) {
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.
+ }
+
+
+ /**
+ * 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
+ * @returns {Promise<void>}
+ */
+ async createIdentifier(dbCtx, identifier, credential, otpKey = null) {
+ const _scope = _fileScope('createIdentifier');
+ try {
+ const secureCredential = await this._secureCredential(credential);
+ await this.db.authenticationUpsert(dbCtx, identifier, secureCredential, otpKey);
+ } catch (e) {
+ this.logger.error(_scope, 'failed', { error: e, identifier });
+ throw e;
+ }
+ }
+
+
+ /**
+ * Update the authentication database with a new secured credential
+ * for an indentifier.
+ * @param {*} dbCtx
+ * @param {*} identifier
+ * @param {*} credential plaintext
+ * @returns {Promise<void>}
+ */
+ async updateCredential(dbCtx, identifier, credential) {
+ const _scope = _fileScope('updateCredential');
+ try {
+ const secureCredential = await this._secureCredential(credential);
+ await this.db.authenticationUpdateCredential(dbCtx, identifier, secureCredential);
+ this.logger.info(_scope, 'success', { identifier });
+ } catch (e) {
+ this.logger.error(_scope, 'failed', { error: e, identifier });
+ throw e;
+ }
+ }
+
+
+ /**
+ * Encode a plaintext credential in the preferred way to store in database.
+ * @param {String} credential
+ * @param {String=} authn
+ * @returns {Promise<String>}
+ */
+ async _secureCredential(credential, authn = this.authnPreferred) {
+ const _scope = _fileScope('_secureCredential');
+ try {
+ switch (authn) {
+ case 'plain':
+ return '$plain$' + credential;
+
+ case 'argon2':
+ return await this.authn.argon2.hash(credential, { type: this.authn.argon2.argon2id });
+
+ default:
+ throw new RangeError('Unknown authn');
+ }
+ } catch (e) {
+ this.logger.error(_scope, 'failed', { error: e, authn });
+ throw e;
+ }
+ }
+
+
+ /**
+ * Checks a supplied credential against supplied data.
+ * @param {AuthInfo} authData from database
+ * @param {String} credential plaintext
+ * @returns {Promise<Boolean>}
+ */
+ async _validateAuthDataCredential(authData, credential) {
+ const _scope = _fileScope('_validateAuthDataCredential');
+
+ if (!authData?.credential) {
+ this.logger.debug(_scope, 'failed, no authInfo');
+ return false;
+ }
+ if (authData.credential.startsWith('$argon2')
+ && this.authnEnabled.has('argon2')) {
+ return await this._isValidArgon2Identifier(authData, credential);
+ }
+ if (authData.credential.startsWith('$PAM$')
+ && this.authnEnabled.has('pam')) {
+ return await this._isValidPAMIdentifier(authData, credential);
+ }
+ if (authData.credential.startsWith('$plain$')
+ && this.authnEnabled.has('plain')) {
+ return this.constructor._isValidPlainIdentifier(authData, credential);
+ }
+ this.logger.error(_scope, 'failed, unknown or unsupported type of stored credential', { authData });
+ return false;
+ }
+
+
+ /**
+ * Check argon2.
+ * @param {AuthInfo} authData
+ * @param {String} credential
+ * @returns {Promise<Boolean>}
+ */
+ async _isValidArgon2Identifier(authData, credential) {
+ return await this.authn.argon2.verify(authData.credential, credential);
+ }
+
+
+ /**
+ * Check plaintext.
+ * @param {AuthInfo} authData
+ * @param {String} credential
+ * @returns {Promise<Boolean>}
+ */
+ static _isValidPlainIdentifier(authData, credential) {
+ return authData.credential.substring('$plain$'.length) === credential;
+ }
+
+
+ /**
+ * Check system PAM.
+ * @param {AuthInfo} authData
+ * @param {String} credential
+ * @returns {Promise<Boolean>}
+ */
+ async _isValidPAMIdentifier(authData, credential) {
+ const _scope = _fileScope('_isValidPAMIdentifier');
+ const { identifier } = authData;
+ 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;
}
/**
* 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<Boolean>}
*/
async isValidIdentifierCredential(identifier, credential, ctx) {
const _scope = _fileScope('isValidIdentifierCredential');
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 {
- this.logger.error(_scope, 'failed, unknown type of stored credential', { identifier, ctx });
- }
+ return;
}
- if (this.authnEnabled.includes('DEBUG_ANY')) {
- isValid = true;
- }
+ isValid = await this._validateAuthDataCredential(authData, credential);
if (isValid) {
ctx.authenticationId = identifier;
+ if (authData.otpKey) {
+ ctx.otpKey = authData.otpKey;
+ }
await this.db.authenticationSuccess(dbCtx, identifier);
}
}); // dbCtx
/**
- * Check system PAM.
- * @param {String} identifier
- * @param {String} credential
- * @returns {Boolean}
+ *
+ * @param {OTPState} state
+ * @param {String} state.key
+ * @param {Number} state.attempt
+ * @param {Number} state.epochMs
+ * @param {String} otp
+ * @returns {String} Enum.OTPResult
*/
- async _isValidPAMIdentifier(identifier, credential) {
- const _scope = _fileScope('_isValidPAMIdentifier');
- let isValid = false;
- if (this.forbiddenPAMIdentifiers.includes(identifier)) {
- return false;
+ checkOTP(state, otp) {
+ const totp = new this.TOTP({
+ keyEncoding: 'base32',
+ ...this.options?.authenticator?.otpOptions,
+ key: state.key,
+ });
+ const isValid = totp.validate(otp);
+ if (isValid) {
+ return Enum.OTPResult.Valid;
}
- 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;
- }
+ 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 isValid;
+ 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<Boolean>}
*/
async isValidBasic(credentials, ctx) {
const _scope = _fileScope('isValidBasic');
* Determine which Authorization header is available, and if it is valid.
* @param {String} authorizationHeader
* @param {Object} ctx
+ * @returns {Promise<Boolean>}
*/
async isValidAuthorization(authorizationHeader, ctx) {
const _scope = _fileScope('isValidAuthorization');
}
- /**
- *
- * @param {String} cookieHeader
- */
- static _cookieParse(cookieHeader) {
- const cookie = {};
- (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('"')) {
- cookie[name] = value.slice(1, -1); // eslint-disable-line security/detect-object-injection
- } else {
- cookie[name] = value; // eslint-disable-line security/detect-object-injection
- }
- }
- });
- return cookie;
- }
-
-
/**
* Attempt to parse a session cookie, and determine if it contains an
* authenticated user.
- * Restores ctx.session from cookie data, sets ctx.authenticationId if identifier exists.
+ * Restores ctx.session from cookie data, sets ctx.authenticationId to
+ * identifier or profile for session.
* @param {Object} ctx
- * @param {String} cookieHeader
- * @returns {Boolean}
+ * @param {Object} ctx.cookie
+ * @returns {Promise<Boolean>}
*/
- async isValidCookieAuth(ctx, cookieHeader) {
+ async isValidCookieAuth(ctx) {
const _scope = _fileScope('isValidCookieAuth');
- this.logger.debug(_scope, 'called', { ctx, cookieHeader });
+ this.logger.debug(_scope, 'called', { ctx });
- const cookie = Authenticator._cookieParse(cookieHeader);
- const cookieValue = cookie[Enum.SessionCookie];
+ const cookieValue = ctx.cookie?.[Enum.SessionCookie];
if (!cookieValue) {
return false;
this.logger.debug(_scope, 'unpacked cookie', { ctx });
const hasIdentifier = !!ctx.session.authenticatedIdentifier;
- const hasProfile = !!ctx.session.authenticatedProfile && this.authnEnabled.includes('indieAuth');
+ const hasProfile = !!ctx.session.authenticatedProfile && this.authnEnabled.has('indieAuth');
const isValid = hasIdentifier || hasProfile;
if (isValid) {
ctx.authenticationId = ctx.session.authenticatedIdentifier || ctx.session.authenticatedProfile;
return isValid;
} catch (e) {
- this.logger.debug(_scope, 'could not unpack cookie', { error:e, ctx });
+ this.logger.debug(_scope, 'could not unpack cookie', { error: e, ctx });
return false;
}
}
/**
* Check for a valid session.
+ * If a valid session cookie is present, refreshes the timeout for it.
+ * If not, and one is required, redirects to the login url.
+ * Convenience wrappers for option combinations:
+ * @see sessionRequired
+ * @see sessionRequiredLocal
+ * @see sessionOptional
+ * @see sessionOptionalLocal
* @param {http.ClientRequest} req
* @param {http.ServerResponse} res
* @param {Object} ctx
* @param {String} loginPath
- * @param {Boolean} required
- * @param {Boolean} profilesAllowed
- * @returns {Boolean}
+ * @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<Boolean>}
*/
async sessionCheck(req, res, ctx, loginPath, required = true, profilesAllowed = true) {
- const _scope = _fileScope('check');
+ const _scope = _fileScope('sessionCheck');
this.logger.debug(_scope, 'called', { ctx, loginPath, required, profilesAllowed });
if (this.secureAuthOnly
}
}
- const sessionCookie = req.getHeader(Enum.Header.Cookie);
- if (sessionCookie
- && await this.isValidCookieAuth(ctx, sessionCookie)
+ if (await this.isValidCookieAuth(ctx)
&& (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('; '));
+ common.addCookie(res, Enum.SessionCookie, ctx.cookie[Enum.SessionCookie], {
+ httpOnly: true,
+ maxAge: this.cookieLifespan,
+ sameSite: 'Lax',
+ path: `${this.proxyPrefix}/`,
+ secure: this.secureAuthOnly,
+ });
return true;
}
if (required) {
// Clear any existing invalid session
- const cookieParts = [
- `${Enum.SessionCookie}=""`,
- 'HttpOnly',
- 'Max-Age=0',
- 'SameSite=Lax',
- `Path=${this.options.dingus.proxyPrefix}/`,
- ];
- if (this.options.authenticator.secureAuthOnly) {
- cookieParts.push('Secure');
- }
- res.setHeader(Enum.Header.SetCookie, cookieParts.join('; '));
+ common.addCookie(res, Enum.SessionCookie, '""', {
+ httpOnly: true,
+ maxAge: 0,
+ sameSite: 'Lax',
+ path: `${this.proxyPrefix}/`,
+ secure: this.secureAuthOnly,
+ });
res.statusCode = 302;
res.setHeader(Enum.Header.Location, `${loginPath}?r=${encodeURIComponent(req.url)}`);
* @param {http.ServerResponse} res
* @param {Object} ctx
* @param {String} loginPath
- * @returns {Boolean}
+ * @returns {Promise<Boolean>}
*/
async sessionRequiredLocal(req, res, ctx, loginPath) {
return this.sessionCheck(req, res, ctx, loginPath, true, false);
* @param {http.ServerResponse} res
* @param {Object} ctx
* @param {String} loginPath
- * @returns {Boolean}
+ * @returns {Promise<Boolean>}
*/
async sessionRequired(req, res, ctx, loginPath) {
return this.sessionCheck(req, res, ctx, loginPath);
* @param {http.ServerResponse} res
* @param {Object} ctx
* @param {String} loginPath
- * @returns {Boolean}
+ * @returns {Promise<Boolean>}
*/
async sessionOptionalLocal(req, res, ctx) {
return this.sessionCheck(req, res, ctx, undefined, false, false);
* @param {http.ServerResponse} res
* @param {Object} ctx
* @param {String} loginPath
- * @returns {Boolean}
+ * @returns {Promise<Boolean>}
*/
async sessionOptional(req, res, ctx) {
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.
+ * 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<Boolean}
*/
async apiRequiredLocal(req, res, ctx, sessionAlsoValid = true) {
const _scope = _fileScope('apiRequiredLocal');
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;
}
this.logger.debug(_scope, 'invalid authorization', { ctx, sessionAlsoValid });
this.requestBasic(res);
}
+
}
module.exports = Authenticator;