* 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<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
+ * @typedef {object} AuthDBInterface
+ * @property {(DBContextExec) => Promise<any>} context db context runner
+ * @property {(dbCtx: any, identifier: string) => Promise<AuthInfo>} authenticationGet get an authentication entry
+ * @property {(dbCtx: any, identifier: string) => Promise<void>} authenticationSuccess store a successful authentication event
+ * @property {(dbCtx: any, identifier: string, credential: string, otpKey: string=) => Promise<void>} authenticationUpsert update an authentication entry
+ * @property {(dbCtx: any, identifier: string, otpKey: string) => Promise<void>} authenticationUpdateOTPKey update otp key
+ * @property {(dbCtx: any, identifier: string, credential: AuthInfo) => Promise<void>} 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;
};
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'];
/**
* 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<void>}
*/
async createIdentifier(dbCtx, identifier, credential, otpKey = null) {
/**
* 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<void>}
*/
async updateCredential(dbCtx, identifier, credential) {
/**
* Encode a plaintext credential in the preferred way to store in database.
- * @param {String} credential
- * @param {String=} authn
- * @returns {Promise<String>}
+ * @param {string} credential plaintext
+ * @param {string=} authn authentication mechanism
+ * @returns {Promise<string>} encoded credential
*/
async _secureCredential(credential, authn = this.authnPreferred) {
const _scope = _fileScope('_secureCredential');
/**
* Checks a supplied credential against supplied data.
* @param {AuthInfo} authData from database
- * @param {String} credential plaintext
- * @returns {Promise<Boolean>}
+ * @param {string} credential plaintext
+ * @returns {Promise<boolean>} is valid
*/
async _validateAuthDataCredential(authData, credential) {
const _scope = _fileScope('_validateAuthDataCredential');
/**
* Check argon2.
- * @param {AuthInfo} authData
- * @param {String} credential
- * @returns {Promise<Boolean>}
+ * @param {AuthInfo} authData auth entry
+ * @param {string} credential to check
+ * @returns {Promise<boolean>} is valid
*/
async _isValidArgon2Identifier(authData, credential) {
return await this.authn.argon2.verify(authData.credential, credential);
/**
* Check plaintext.
- * @param {AuthInfo} authData
- * @param {String} credential
- * @returns {Promise<Boolean>}
+ * @param {AuthInfo} authData auth entry
+ * @param {string} credential to check
+ * @returns {Promise<boolean>} is valid
*/
static _isValidPlainIdentifier(authData, credential) {
return authData.credential.substring('$plain$'.length) === credential;
/**
* Check system PAM.
- * @param {AuthInfo} authData
- * @param {String} credential
- * @returns {Promise<Boolean>}
+ * @param {AuthInfo} authData auth entry
+ * @param {string} credential to check
+ * @returns {Promise<boolean>} is valid
*/
async _isValidPAMIdentifier(authData, credential) {
const _scope = _fileScope('_isValidPAMIdentifier');
* 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>}
+ * @param {string} identifier identifier
+ * @param {string} credential to check
+ * @param {object} ctx context
+ * @returns {Promise<boolean>} is valid
*/
async isValidIdentifierCredential(identifier, credential, ctx) {
const _scope = _fileScope('isValidIdentifierCredential');
/**
- *
- * @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({
}
+ /**
+ * 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<Boolean>}
+ * @param {string} authValue basic auth value (base64)
+ * @param {object} ctx context
+ * @returns {Promise<boolean>} 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);
/**
* Determine which Authorization header is available, and if it is valid.
- * @param {String} authorizationHeader
- * @param {Object} ctx
- * @returns {Promise<Boolean>}
+ * @param {string} authorizationHeader request header
+ * @param {object} ctx context
+ * @returns {Promise<boolean>} is valid
*/
async isValidAuthorization(authorizationHeader, ctx) {
const _scope = _fileScope('isValidAuthorization');
// 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:
/**
* 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"`);
* 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<Boolean>}
+ * @param {object} ctx context
+ * @param {object} ctx.cookie cookies object
+ * @returns {Promise<boolean>} is valid
*/
async isValidCookieAuth(ctx) {
const _scope = _fileScope('isValidCookieAuth');
* @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<Boolean>}
+ * @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<boolean>} is valid session
*/
async sessionCheck(req, res, ctx, loginPath, required = true, profilesAllowed = true) {
const _scope = _fileScope('sessionCheck');
/**
* 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<Boolean>}
+ * @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<boolean>} is valid session
*/
async sessionRequiredLocal(req, res, ctx, loginPath) {
return this.sessionCheck(req, res, ctx, loginPath, true, false);
/**
* 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<Boolean>}
+ * @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<boolean>} is valid session
*/
async sessionRequired(req, res, ctx, loginPath) {
return this.sessionCheck(req, res, ctx, loginPath);
/**
* 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<Boolean>}
+ * @param {http.ClientRequest} req request
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @returns {Promise<boolean>} is valid session
*/
async sessionOptionalLocal(req, res, ctx) {
return this.sessionCheck(req, res, ctx, undefined, false, false);
/**
* 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<Boolean>}
+ * @param {http.ClientRequest} req request
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @returns {Promise<boolean>} is valid session
*/
async sessionOptional(req, res, ctx) {
return this.sessionCheck(req, res, ctx, undefined, false);
* 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}
+ * @param {http.ClientRequest} req request
+ * @param {http.ServerResponse} res response
+ * @param {object} ctx context
+ * @param {boolean} sessionAlsoValid fall back to session if no authz header
+ * @returns {Promise<boolean>} 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 });