serverside protocol work for authentication enforcement and configuration

This commit is contained in:
ansuz 2023-05-05 18:17:58 +05:30
parent b753a067ac
commit 41e870d3db
3 changed files with 835 additions and 5 deletions

View File

@ -0,0 +1,337 @@
/* globals Buffer */
const B32 = require("thirty-two");
const OTP = require("notp");
const JWT = require("jsonwebtoken");
const nThen = require("nthen");
const Util = require("../common-util");
const MFA = require("../storage/mfa");
const Sessions = require("../storage/sessions");
const Block = require("../storage/block");
const Commands = module.exports;
var isString = s => typeof(s) === 'string';
// basic definition of what we'll accept as an OTP code
// exactly six numerical digits
var isValidOTP = otp => {
return isString(otp) &&
// in the future this could be updated to support 8 digits
otp.length === 6 &&
// \D is non-digit characters, so this tests that it is exclusively numeric
!/\D/.test(otp);
};
// we'll only allow users to set up multi-factor auth
// for keypairs they control which already have blocks
// this check doesn't confirm that their id is valid base64
// any attempt relying on this should fail when we can't decode
// the id they provided.
var isValidBlockId = id => {
return id && isString(id) && id.length === 44;
};
// the base32 library can throw when decoding under various conditions.
// we have some basic requirements for the length of base32 as well,
// so we just do all the validation here. It either returns a buffer
// of length 20 or undefined, so the caller can just check whether it's
// falsey and otherwise assume it was well-formed
// Length === 20 comes from the recommendation of 160 bits of entropy
// in RFC4226 (https://www.rfc-editor.org/rfc/rfc4226#section-4)
var decode32 = S => {
let decoded;
try {
decoded = B32.decode(S);
} catch (err) { return; }
if (!(decoded instanceof Buffer) || decoded.length !== 20) { return; }
return decoded;
};
var createJWT = function (Env, sessionId, publicKey, cb) {
JWT.sign({
// this is a custom JWT field (not a standard) - we include a reference to the session
// which is used to look up whether it has been revoked.
ref: sessionId,
// we specify in the token for what resource the token should be valid (their block's public key)
sub: Util.escapeKeyCharacters(publicKey),
}, Env.bearerSecret, {
// token integrity is ensured with HMAC SHA512 with the server's bearerSecret
// clients can inspect token parameters, but cannot modify them
algorithm: 'HS512',
// if you want it to expire you can set this for an arbitrary number of seconds in the future, but I won't assume that for now
//expiresIn: (60 * 60 * 24 * 7)),
}, function (err, token) {
if (err) { return void cb(err); }
cb(void 0, token);
});
};
// This command allows clients to configure TOTP as a second factor protecting
// their login block IFF they:
// 1. provide a sufficiently strong TOTP secret
// 2. are able to produce a valid OTP code for that secret (indicating that their clock is sufficiently close to ours)
// 3. such a login block actually exists
// 4. are able to sign an arbitrary message for the login block's public key
// 5. have not already configured TOTP protection for this account
// (changing to a new secret can be done by disabling and re-enabling TOTP 2FA)
const TOTP_SETUP = Commands.TOTP_SETUP = function (Env, body, cb) {
const { publicKey, secret, code, contact } = body;
// the client MUST provide an OTP code of the expected format
// this doesn't check if it matches the secret and time, just that it's well-formed
if (!isValidOTP(code)) { return void cb("E_INVALID"); }
// if they provide an (optional) point of contact as a recovery mechanism then it should be a string.
// the intent is to allow to specify some side channel for those who inevitably lock themselves out
// we should be able to use that to validate their identity.
// I don't want to assume email, but limiting its length to 254 (the maximum email length) seems fair.
if (contact && (!isString(contact) || contact.length > 254)) { return void cb("INVALID_CONTACT"); }
// Check that the provided public key is the expected format for a block
if (!isValidBlockId(publicKey)) {
return void cb("INVALID_KEY");
}
// decode32 checks whether the secret decodes to a sufficiently long buffer
var decoded = decode32(secret);
if (!decoded) { return void cb('INVALID_SECRET'); }
// Reject attempts to setup TOTP if a record of their preferences already exists
MFA.read(Env, publicKey, function (err) {
// There **should be** an error here, because anything else
// means that a record already exists
// This may need to be adjusted as other methods of MFA are added
if (!err) { return void cb("EEXISTS"); }
// if no MFA settings exist then we expect ENOENT
// anything else indicates a problem and should result in rejection
if (err.code !== 'ENOENT') { return void cb(err); }
try {
// allow for 30s of clock drift in either direction
// returns an object ({ delta: 0 }) indicating the amount of clock drift
// if successful, otherwise `null`
var validated = OTP.totp.verify(code, decoded, {
window: 1,
});
if (!validated) { return void cb("INVALID_OTP"); }
cb();
} catch (err2) {
Env.Log.error('TOTP_SETUP_VERIFICATION_ERROR', {
error: err2,
});
return void cb("INTERNAL_ERROR");
}
});
};
// The 'complete' step for TOTP_SETUP will only be called if the client
// passed earlier validation and successfully signed the server's challenge.
// There's still a little bit more to do and it could still fail.
TOTP_SETUP.complete = function (Env, body, cb) {
// the OTP code should have already been validated
var { publicKey, secret, contact } = body;
// the device from which they configure MFA settings
// is assumed to be safe, so we'll respond with a JWT token
// the remainder of the setup is successfully completed.
// Otherwise they would have to reauthenticate.
// The session id is used as a reference to this particular session.
const sessionId = Sessions.randomId();
var token;
nThen(function (w) {
// confirm that the block exists
Block.check(Env, publicKey, w(function (err) {
if (err) {
Env.Log.error("TOTP_SETUP_NO_BLOCK", {
publicKey,
});
w.abort();
return void cb("NO_BLOCK");
}
// otherwise the block exists, continue
}));
}).nThen(function (w) {
// store the data you'll need in the future
var data = {
method: 'TOTP', // specify this so it's easier to add other methods later?
secret: secret, // the 160 bit, base32-encoded secret that is used for OTP validation
creation: new Date(), // the moment at which the MFA was configured
};
if (isString(contact)) {
// 'contact' is an arbitary (and optional) string for manual recovery from 2FA auth fails
// it should already be validated
data.contact = contact;
}
// We attempt to store a record of the above preferences
// if it fails then we abort and inform the client of an error.
MFA.write(Env, publicKey, JSON.stringify(data), w(function (err) {
if (err) {
w.abort();
Env.Log.error("TOTP_SETUP_STORAGE_FAILURE", {
publicKey: publicKey,
error: err,
});
return void cb('STORAGE_FAILURE');
}
// otherwise continue
}));
}).nThen(function (w) {
// generate a bearer token and store it
createJWT(Env, sessionId, publicKey, w(function (err, _token) {
if (err) {
// we have already stored the MFA data, which will cause access to the resource to be restricted to the provided TOTP secret.
// we attempt to create a session as a matter of convenience - so if it fails
// that just means they'll be forced to authenticate
Env.Log.error("TOTP_SETUP_JWT_SIGN_ERROR", {
error: err,
publicKey: publicKey,
});
return void cb('TOKEN_ERROR');
}
token = _token;
}));
}).nThen(function (w) {
// store the token
Sessions.write(Env, publicKey, sessionId, token, w(function (err) {
if (err) {
// again, if there's a failure here the user should automatically
// be forced to reauthenticate because their block is protected
// but they will not have a valid JWT allowing them to access it
Env.Log.error("TOTP_SETUP_SESSION_WRITE", {
error: err,
publicKey: publicKey,
sessionId: sessionId,
});
w.abort();
return void cb("SESSION_WRITE_ERROR");
}
// else continue
}));
}).nThen(function () {
// respond with the stored token that they can now use to authenticate
cb(void 0, {
bearer: token,
});
});
};
// This command is somewhat simpler than TOTP_SETUP
// Issue a client a JWT which will allow them to access a login block IFF:
// 1. That login block exists
// 2. That login block is protected by TOTP 2FA
// 3. They can produce a valid OTP for that block's TOTP secret
// 4. They can sign for the block's public key
const validate = Commands.TOTP_VALIDATE = function (Env, body, cb) {
var { publicKey, code } = body;
// they must provide a valid OTP code
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
var secret;
nThen(function (w) {
// check that there is an MFA configuration for the given account
MFA.read(Env, publicKey, w(function (err, content) {
if (err) {
w.abort();
Env.Log.error('TOTP_VALIDATE_MFA_READ', {
error: err,
publicKey: publicKey,
});
return void cb('NO_MFA_CONFIGURED');
}
var parsed = Util.tryParse(content);
if (!parsed) {
w.abort();
return void cb("INVALID_CONFIGURATION");
}
secret = parsed.secret;
}));
}).nThen(function () {
let decoded = decode32(secret);
if (!decoded) {
Env.Log.error("TOTP_VALIDATE_INVALID_SECRET", {
publicKey, // log the public key so the admin can investigate further
// don't log the problematic secret directly as
// logs are likely to be pasted in random places
});
return void cb("E_INVALID_SECRET");
}
// validate the code
var validated = OTP.totp.verify(code, decoded, {
window: 1,
});
if (!validated) {
// I won't worry about logging these OTPs as they shouldn't leak any useful information
Env.Log.error("TOTP_VALIDATE_BAD_OTP", {
code,
});
return void cb("INVALID_OTP");
}
// call back to indicate that their request was well-formed and valid
cb();
});
};
validate.complete = function (Env, body, cb) {
/*
if they are here then they:
1. have a valid block configured with TOTP-based 2FA
2. were able to provide a valid TOTP for that block's secret
3. were able to sign their messages for the block's public key
So, we should:
1. instanciate a session for them by generating and storing a token for their public key
2. send them the token
*/
var { publicKey } = body;
const sessionId = Sessions.randomId();
var token;
nThen(function (w) {
createJWT(Env, sessionId, publicKey, w(function (err, _token) {
if (err) {
Env.Log.error("TOTP_VALIDATE_JWT_SIGN_ERROR", {
error: err,
publicKey: publicKey,
});
return void cb("TOKEN_ERROR");
}
token = _token;
}));
}).nThen(function (w) {
// store the token
Sessions.write(Env, publicKey, sessionId, token, w(function (err) {
if (err) {
Env.Log.error("TOTP_VALIDATE_SESSION_WRITE", {
error: err,
publicKey: publicKey,
sessionId: sessionId,
});
w.abort();
return void cb("SESSION_WRITE_ERROR");
}
// else continue
}));
}).nThen(function () {
cb(void 0, {
bearer: token,
});
});
};

313
lib/http-commands.js Normal file
View File

@ -0,0 +1,313 @@
var Nacl = require("tweetnacl/nacl-fast");
var Util = require('./common-util.js');
var Challenge = require("./storage/challenge.js");
// C.read(Env, id, cb)
// C.write(Env,id, data, cb)
// C.delete(Env, id, cb)
/*
The API for command definition consists of two stages:
Clients first send a command and its associated parameters.
The server validates that the command is supported, and that
the provided parameters are valid. If it fails validation for any reason,
the server responds with an error and the protocol is aborted.
COMMANDS[COMMAND_NAME] = function (Env, body, cb) {
// inspect parameters in the request body
if (!body.essential_parameter) {
return void cb('NO');
}
cb();
};
Commands whose parameters are successfully validated
have those parameters stored on the disk (or a relational DB in the future).
The server then requests that the client sign their well-formulated
command along with a server-generated transaction id ('txid': randomized to prevent replays)
and a date (so that it can ensure that the client responds within a reasonable window.
Clients then respond with a txid and a cryptographic signature
which matches the parameters of the command. The server loads the command
with the corresponding txid, checks that it was signed within a reasonable time window,
validates the signature, and attempts to complete the command's execution:
COMMAND[COMMAND_NAME].complete = function (Env, body, cb) {
doAThing(function (err, values) {
if (err) {
// Log the error and respond that the command was not successful
return void cb("SORRY_BUT_IM_NOT_OK");
}
cb(void 0, {
arbitrary: values,
});
});
};
In this second stage the protocol can be aborted if the client has done something wrong:
(ie. if it did not produce a valid signature for the command)
or it can can fail because the server was not able to complete the requested task
(ie. because of an I/O error or because an error was thrown and caught).
It is intended that the server will respond with an appropriate error if
the request cannot be completed, and it will respond OK if everything completed successfully.
*/
var COMMANDS = {};
// Methods allowing clients to configure Time-based One-Time Passwords for their login-block,
// and to authenticate new sessions once a TOTP secret has been associated with their account,
const TOTP = require("./challenge-commands/totp.js");
COMMANDS.TOTP_SETUP = TOTP.TOTP_SETUP;
COMMANDS.TOTP_VALIDATE = TOTP.TOTP_VALIDATE;
var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');
// this function handles the first stage of the protocol
// (the server's validation of the client's request and the generation of its challenge)
var handleCommand = function (Env, req, res) {
var body = req.body;
var command = body.command;
// reject if the command does not have a corresponding function
if (typeof(COMMANDS[command]) !== 'function') {
Env.Log.error('CHALLENGE_UNSUPPORTED_COMMAND', command);
return void res.status(500).json({
error: 'invalid command',
});
}
var publicKey = body.publicKey;
// reject if they did not provide a valid public key
if (!publicKey || typeof(publicKey) !== 'string' || publicKey.length !== 44) {
Env.Log.error('CHALLENGE_INVALID_KEY', publicKey);
return void res.status(500).json({
error: 'Invalid key',
});
}
try {
COMMANDS[command](Env, body, function (err) {
if (err) {
Env.Log.error('CHALLENGE_COMMAND_EXECUTION_ERROR', {
body: body,
error: err,
});
// errors returned from commands are passed back to the client
// as a weak precaution, we try to only send an error's message
// if one exists. This makes it less likely that we'll respond with any
// sensitive information in a stack trace. Ideally functions should
// only return error messages or codes in the form of a string or number,
// but mistakes happen.
return void res.status(500).json({
error: (err && err.message) || err,
});
}
var txid = randomToken();
var date = new Date().toISOString();
var copy = Util.clone(body);
copy.txid = txid;
copy.date = date;
// Write the command and challenge to disk, because the challenge protocol
// is interactive and the subsequent response might be handled by a different http worker
// this makes it so we can avoid holding state in memory
Challenge.write(Env, txid, JSON.stringify(copy), function (err) {
if (err) {
Env.Log.error('CHALLENGE_WRITE_ERROR', err);
return void res.status(500).json({
// arbitrary error message, only intended for debugging
error: 'Internal server error 6250',
});
}
// respond with challenge parameters
return void res.status(200).json({
txid: txid,
date: date,
});
});
});
} catch (err) {
Env.Log.error("CHALLENGE_COMMAND_THROWN_ERROR", {
error: err,
});
return void res.status(500).json({
// arbitrary error message, only intended for debugging
error: 'Internal server error 7692',
});
}
};
// this function handles the second stage of the protocol
// (the client's response to the server's challenge)
var handleResponse = function (Env, req, res) {
var body = req.body;
if (Object.keys(body).some(k => !/(sig|txid)/.test(k))) {
Env.Log.error("CHALLENGE_RESPONSE_DEBUGGING", body);
// we expect the response to only have two keys
// if any more are present then the response is malformed
return void res.status(500).json({
error: 'extraneous parameters',
});
}
// transaction ids are issued to the client by the server
// they allow it to recall the full details of the challenge
// to which the client is responding
var txid = body.txid;
// if no txid is present, then the server can't look up the corresponding challenge
// the response is definitely malformed, so reject it.
// Additionally, we expect txids to be 32 characters long (24 Uint8s as base64)
// reject txids of any other length
if (!txid || typeof(txid) !== 'string' || txid.length !== 32) {
Env.Log.error('CHALLENGE_RESPONSE_BAD_TXID', body);
return void res.status(500).json({
error: "Invalid txid",
});
}
var sig = body.sig;
if (!sig || typeof(sig) !== 'string' || sig.length !== 88) {
Env.Log.error("CHALLENGE_RESPONSE_BAD_SIG", body);
return void res.status(500).json({
error: "Missing signature",
});
}
Challenge.read(Env, txid, function (err, text) {
if (err) {
Env.Log.error("CHALLENGE_READ_ERROR", {
txid: txid,
error: err,
});
return void res.status(500).json({
error: "Unexpected response",
});
}
// garbage collection can clean this up later
Challenge.delete(Env, txid, function (err) {
if (err) {
Env.Log.error("CHALLENGE_DELETION_ERROR", {
txid: txid,
error: err,
});
}
});
var json = Util.tryParse(text);
if (!json) {
Env.Log.error("CHALLENGE_PARSE_ERROR", {
txid: txid,
});
return void res.status(500).json({
error: "Internal server error 129",
});
}
var publicKey = json.publicKey;
if (!publicKey || typeof(publicKey) !== 'string') {
// This shouldn't happen, as we expect that the server
// will have validated the key to an extent before storing the challenge
Env.Log.error('CHALLENGE_INVALID_PUBLICKEY', {
publicKey: publicKey,
});
return res.status(500).json({
error: "Invalid public key",
});
}
var action;
try {
action = COMMANDS[json.command].complete;
} catch (err2) {}
if (typeof(action) !== 'function') {
Env.Log.error("CHALLENGE_RESPONSE_ACTION_NOT_IMPLEMENTED", json.command);
return res.status(501).json({
error: 'Not implemented',
});
}
var u8_toVerify,
u8_sig,
u8_publicKey;
try {
u8_toVerify = Nacl.util.decodeUTF8(text);
u8_sig = Nacl.util.decodeBase64(sig);
u8_publicKey = Nacl.util.decodeBase64(publicKey);
} catch (err3) {
Env.Log.error('CHALLENGE_RESPONSE_DECODING_ERROR', {
text: text,
sig: sig,
publicKey: publicKey,
error: err3,
});
return res.status(500).json({
error: "decoding error"
});
}
// validate the response
var success = Nacl.sign.detached.verify(u8_toVerify, u8_sig, u8_publicKey);
if (success !== true) {
Env.Log.error("CHALLENGE_RESPONSE_SIGNATURE_FAILURE", {
publicKey,
});
return void res.status(500).json({
error: 'Failed signature validation',
});
}
// execute the command
action(Env, json, function (err, content) {
if (err) {
Env.Log.error("CHALLENGE_RESPONSE_ACTION_ERROR", {
error: err,
});
return res.status(500).json({
error: 'Execution error',
});
}
res.status(200).json(content);
});
});
};
module.exports.handle = function (Env, req, res /*, next */) {
var body = req.body;
// we expect that the client has posted some JSON data
if (!body) {
return void res.status(500).json({
error: 'invalid request',
});
}
// we only expect responses to challenges to have a 'txid' attribute
// further validation is performed in handleResponse
if (body.txid) {
return void handleResponse(Env, req, res);
}
// we only expect initial requests to have a 'command' attribute
// further validation is performed in handleCommand
if (body.command) {
return void handleCommand(Env, req, res);
}
// if a request is neither a command nor a response, then reject it with an error
res.status(500).json({
error: 'invalid request',
});
};

View File

@ -6,11 +6,17 @@ const Fs = require("node:fs");
const nThen = require("nthen");
const Util = require("./common-util");
const Logger = require("./log");
const AuthCommands = require("./http-commands");
const JWT = require("jsonwebtoken");
const MFA = require("./storage/mfa");
const Sessions = require("./storage/sessions");
const DEFAULT_QUERY_TIMEOUT = 5000;
const PID = process.pid;
var Env = JSON.parse(process.env.Env);
const response = Util.response(function (errLabel, info) {
if (!Env.Log) { return; }
Env.Log.error(errLabel, info);
});
@ -47,10 +53,10 @@ Logger.levels.forEach(level => {
const EVENTS = {};
var Env = JSON.parse(process.env.Env);
EVENTS.ENV_UPDATE = function (data /*, cb */) {
try {
Env = JSON.parse(data);
Env.Log = Log;
} catch (err) {
Log.error('HTTP_WORKER_ENV_UPDATE', Util.serializeError(err));
}
@ -241,9 +247,6 @@ app.use(function (req, res, next) {
app.use(Express.static(Path.resolve('./customize/www')));
app.use(Express.static(Path.resolve('./www')));
// FIXME I think this is a regression caused by a recent PR
// correct this hack without breaking the contributor's intended behaviour.
var mainPages = Env.mainPages || Default.mainPages();
var mainPagePattern = new RegExp('^\/(' + mainPages.join('|') + ').html$');
app.get(mainPagePattern, Express.static('./customize'));
@ -253,7 +256,175 @@ app.use("/blob", Express.static(Path.resolve(Env.paths.blob), {
maxAge: Env.DEV_MODE? "0d": "365d"
}));
// XXX update atime?
app.use('/block/', function (req, res, next) {
var parsed = Path.parse(req.url);
var name = parsed.name;
// block access control only applies to files
// identified by base64-encoded public keys
// skip everything else, ie. /block/placeholder.txt
if (typeof(name) !== 'string' || name.length !== 44) {
return void next();
}
var authorization = req.headers.authorization;
var mfa_params, jwt_payload;
nThen(function (w) {
// First, check whether the block id in question has any MFA settings stored
MFA.read(Env, name, w(function (err, content) {
// ENOENT means there are no settings configured
// it could be a 404 or an existing block without MFA protection
// in either case you can abort and fall through
// allowing the static webserver to handle either case
if (err && err.code === 'ENOENT') {
w.abort();
return void next();
}
// we're not expecting other errors. the sensible thing is to fail
// closed - meaning assume some protection is in place but that
// the settings couldn't be loaded for some reason. block access
// to the resource, logging for the admin and responding to the client
// with a vague error code
if (err) {
Log.error('GET_BLOCK_METADATA', err);
return void res.status(500).json({
code: 500,
error: "UNEXPECTED_ERROR",
});
}
// Otherwise, some settings were loaded correctly.
// We're expecting stringified JSON, so try to parse it.
// Log and respond with an error again if this fails.
// If it parses successfully then fall through to the next block.
try {
mfa_params = JSON.parse(content);
} catch (err2) {
w.abort();
Log.error("INVALID_BLOCK_METADATA", err2);
return res.status(500).json({
code: 500,
error: "UNEXPECTED_ERROR",
});
}
}));
}).nThen(function (w) {
// We should only be able to reach this logic
// if we successfully loaded and parsed some JSON
// representing the user's MFA settings.
// Failures at this point relate to insufficient or incorrect authorization.
// This function standardizes how we reject such requests.
// So far the only additional factor which is supported is TOTP.
// We specify what the method is to allow for future alternatives
// and inform the client so they can determine how to respond
// "401" means "Unauthorized"
var no = function () {
w.abort();
res.status(401).json({
method: mfa_params.method,
code: 401
});
};
// if you are here it is because this block is protected by MFA.
// they will need to provide a JSON Web Token, so we can reject them outright
// if one is not present in their authorization header
if (!authorization) { return void no(); }
// The authorization header should be of the form
// "Authorization: Bearer <JWT>"
// We can reject the request if it is malformed.
let token = authorization.replace(/^Bearer\s+/, '').trim();
if (!token) { return void no(); }
// Otherwise we attempt to validate the token
// Successful validation implies that the token was issued by the server
// since only the server should possess the current bearer secret (unless it has leaked).
// It is still possible that the token is not valid for this particular resource,
// so the algorithm (HMAC SHA512) only asserts its integrity, not its validity.
JWT.verify(token, Env.bearerSecret, {
algorithm: 'HS512',
}, w(function (err, payload) {
if (err) {
// the token could not be validated for some reason.
// it might have expired, the server might have rotated secrets,
// it might not be well-formed, etc.
// log and respond.
Log.info('INVALID_JWT', {
error: err,
token: token,
});
return void no();
}
// Now that we have the payload we can inspect its properties
// and reject anything which is obviously wrong without requiring
// any async I/O
// Tokens are issued with a "reference" - a random id which is
// used alongside the block id to look up whether a given session
// is still valid
// reject if it does not provide a lookup reference
if (typeof(payload.ref) !== 'string') {
Log.error("JWT_NO_REFERENCE", payload);
return void no();
}
// A JWT can optionally indicate a finite lifetime.
// reject if it's too old
if (payload.exp && ((+new Date()) > payload.exp)) {
Log.error("JWT_EXPIRED", payload);
return void no();
}
// A JWT indicates the subject (the block id) for which it is valid
// reject if it does not match the block the client is trying to access
if (payload.sub !== name) {
Log.error("JWT_SUBJECT_MISMATCH", payload);
return void no();
}
// otherwise, it seems basically correct.
Log.verbose("VALID_JWT", payload);
// remember the payload for subsequent asynchronous checks
jwt_payload = payload;
}));
}).nThen(function () {
// Finally, even if the JWT itself seems valid, the database
// is the final authority as to whether the session is still valid,
// as it might have been revoked
Sessions.read(Env, name, jwt_payload.ref, function (err /*, content */) {
if (err) {
Log.error('JWT_SESSION_READ_ERROR', err);
return res.status(401).json({
method: mfa_params.method,
code: 401,
});
}
// we could also check whether the content of the file matches the token,
// but clients don't have any influence over the reference and can only
// request to create tokens that are scoped to a public key they control.
// I don' think there's any practical benefit to such a check.
// So, interpret the existence of a file in that location as the continued
// validity of the session. Fall through and let the built-in webserver
// handle the 404 or serving the file.
next();
});
});
});
// TODO this would be a good place to update a block's atime
// in a manner independent of the filesystem. ie. for detecting and archiving
// inactive accounts in a way that will not be invalidated by other forms of access
// like filesystem backups.
app.use("/block", Express.static(Path.resolve(Env.paths.block), {
maxAge: "0d",
}));
@ -423,6 +594,13 @@ app.get('/api/profiling', function (req, res) {
});
});
// This endpoint handles authenticated RPCs over HTTP
// via an interactive challenge-response protocol
app.use(Express.json());
app.post('/api/auth', function (req, res, next) {
AuthCommands.handle(Env, req, res, next);
});
app.use(function (req, res /*, next */) {
if (/^(\/favicon\.ico\/|.*\.js\.map)$/.test(req.url)) {
// ignore common 404s
@ -461,3 +639,5 @@ nThen(function (w) {
});