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} credentials
+ * @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>} authenticationInsertIdentifier
+ * @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');
}
/**
- * Check local auth entries.
- * Sets ctx.authenticatedId if valid.
- * Sets ctx.otpNeeded if account has otpKey.
+ * Populate the authentication database with a new identifier, the
+ * secured credential, and optionally an OTP key.
+ * @param {*} dbCtx
* @param {String} identifier
- * @param {String} credential
- * @param {Object} ctx
- * @returns {Promise<Boolean>}
+ * @param {String} credential plaintext
+ * @param {String=} otpKey
+ * @returns {Promise<void>}
*/
- async isValidIdentifierCredential(identifier, credential, ctx) {
- const _scope = _fileScope('isValidIdentifierCredential');
- this.logger.debug(_scope, 'called', { identifier, credential: '*'.repeat((credential || '').length), ctx });
+ async createIdentifier(dbCtx, identifier, credential, otpKey = null) {
+ const _scope = _fileScope('createIdentifier');
+ try {
+ const secureCredential = await this._secureCredential(credential);
+ await this.db.authenticationInsertIdentifier(dbCtx, identifier, secureCredential, otpKey);
+ } catch (e) {
+ this.logger.error(_scope, 'failed', { error: e, identifier });
+ throw e;
+ }
+ }
- let isValid = false;
- if (typeof credential === 'undefined') {
- return isValid;
+ /**
+ * 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;
}
+ }
- 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 {
- this.logger.error(_scope, 'failed, unknown or unsupported type of stored credential', { identifier, ctx });
- }
- if (this.authnEnabled.includes('DEBUG_ANY')) {
- isValid = true;
- }
+ /**
+ * 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;
- if (isValid) {
- ctx.authenticationId = identifier;
- ctx.otpNeeded = !!authData?.otpKey;
- await this.db.authenticationSuccess(dbCtx, identifier);
+ case 'argon2':
+ return await this.authn.argon2.hash(credential, { type: this.authn.argon2.argon2id });
+
+ default:
+ throw new RangeError('Unknown authn');
}
- }); // dbCtx
+ } catch (e) {
+ this.logger.error(_scope, 'failed', { error: e, authn });
+ throw e;
+ }
+ }
- return isValid;
+
+ /**
+ * 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 {String} identifier
+ * @param {AuthInfo} authData
* @param {String} credential
* @returns {Promise<Boolean>}
*/
- async _isValidPAMIdentifier(identifier, credential) {
+ async _isValidPAMIdentifier(authData, credential) {
const _scope = _fileScope('_isValidPAMIdentifier');
+ const { identifier } = authData;
let isValid = false;
if (this.forbiddenPAMIdentifiers.includes(identifier)) {
return false;
}
+ /**
+ * 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');
+ 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 });
+ return;
+ }
+
+ 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
+
+ return isValid;
+ }
+
+
/**
*
- * @param {Object} state
+ * @param {OTPState} state
* @param {String} state.key
* @param {Number} state.attempt
* @param {Number} state.epochMs
*/
checkOTP(state, otp) {
const totp = new this.TOTP({
+ keyEncoding: 'base32',
...this.options?.authenticator?.otpOptions,
- ...state,
+ key: state.key,
});
const isValid = totp.validate(otp);
if (isValid) {
}
- /**
- *
- * @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?.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.requestBasic(res);
}
+
}
module.exports = Authenticator;