lib/common/auth.js
/**
* @title joola/lib/common/auth
* @overview Provides authentication functionality across the framework.
* @description
* The `auth` module and middleware manages the entire flow relating to authentication, for example: login and token validation.
*
* @copyright (c) Joola Smart Solutions, Ltd. <info@joo.la>
* @license GPL-3.0+ <http://spdx.org/licenses/GPL-3.0+>. Some rights reserved. See LICENSE, AUTHORS
**/
'use strict';
var
joola = require('../joola.js'),
crypto = require('crypto'),
path = require('path'),
url = require('url'),
ce = require('cloneextend'),
router = require('../webserver/routes/index');
var auth = exports;
/**
* @member whitelist_extensions holds the list of whitelisted file extensions. These will be treated as static files and access to them will be allowed.
*/
auth.whitelist_extensions = [
'.js',
'.png'
];
/**
* @member whitelist_endpoints holds the list of whitelisted endpoints. These will be allowed.
*/
auth.whitelist_endpoints = [
'/login',
'/api/users/authenticate'
];
/**
* @member whitelist_params holds the list of whitelisted parameters that may be passed to the routing function. These will be allowed.
*/
auth.whitelist_params = [
'resource',
'action'
];
/**
* @param {Object} err contains the exception details we need to wrap
* Manipulates the object `this` to an Authentication Exception to be returned to the requestor.
*
*/
function AuthErrorTemplate(err) {
this.code = 401;
if (err.message) this.message = err.message; else this.message = err || '';
if (err.stack) this.stack = err.stack; else this.stack = null;
}
AuthErrorTemplate.prototype = Error.prototype;
auth.AuthErrorTemplate = AuthErrorTemplate;
/**
* @param {Object} token that we wish to extend.
* @param {Function} callback called following execution with errors and results.
* Extends a security token expiration.
*
* The function calls on completion an optional `callback` with:
* - `err` if occured, an error object, else null.
* - `result` contains a {Object} with the extended token.
*/
auth.extendToken = function (req, token, callback) {
if (!callback) {
callback = token;
token = req;
req = null;
}
/* istanbul ignore if */
if (token && !token._)
return callback(new Error('Missing token'));
var timestamp = new Date();
token.last = timestamp.getTime();
token.expires = timestamp.setMilliseconds(timestamp.getMilliseconds() + (joola.config.get('authentication:tokens:expireafter') || 20 * 60 * 1000 /*20 minutes*/));
var expiration = (joola.config.get('authentication:tokens:expireafter') || 20 * 60 * 1000 /*20 minutes*/) / 1000;
/* istanbul ignore else */
if (joola.redis) {
joola.redis.expire(joola.config.namespace + ':auth:tokens:' + token._, expiration, function (err, a) {
/* istanbul ignore if */
if (err)
joola.logger.warn({category: 'security'}, '[auth] failed extending security token for [' + token.user.username + ']: ' + err);
else
joola.logger.trace({category: 'security'}, '[auth] extended security token for [' + token.user.username + ']');
return process.nextTick(function () {
return callback(err, token);
});
});
}
else {
joola.memory.set('token:' + token._, joola.memory.get('token:' + token._), expiration * 1000);
joola.logger.trace({category: 'security'}, '[auth] extended security token for [' + token.user.username + ']');
return process.nextTick(function () {
return callback(null, token);
});
}
};
/**
* @param {Object} token that we wish to expire.
* @param {Function} callback called following execution with errors and results.
* Expires a security token.
*
* The function calls on completion an optional `callback` with:
* - `err` if occured, an error object, else null.
*/
auth.expireToken = function (token, callback) {
/* istanbul ignore else */
if (joola.redis) {
joola.memory.set('token:' + token._, null);
joola.redis.del(joola.config.namespace + ':auth:tokens:' + token._, function (err) {
return process.nextTick(function () {
return callback(err);
});
});
} else {
joola.memory.set('token:' + token._, null);
return process.nextTick(function () {
return callback(null);
});
}
};
/**
* @param {Object} user that we wish to generate the token for.
* @param {Function} callback called following execution with errors and results.
* Generates a security token from the user's object.
*
* The function calls on completion an optional `callback` with:
* - `err` if occured, an error object, else null.
* - `result` contains a {Object} with the newly generated token.
*/
auth.generateToken = function (req, user, callback) {
/* istanbul ignore if */
if (!callback) {
callback = user;
user = req;
req = null;
}
//check that we have a valid store that doesn't allow anonymous
callback = callback || function () {
};
var token = {};
if (!user)
return process.nextTick(function () {
return callback(new Error('Missing user for token generation'));
});
var timestamp = new Date();
//TODO: Make sure we have a valid user
if (typeof user === 'string')
try {
user = JSON.parse(user);
} catch (ex) {
return callback(new Error('Invalid user for token generation'));
}
var UserProto = require('../dispatch/prototypes/user.js');
new UserProto({user: joola.SYSTEM_USER}, user.workspace, user, function (err, user) {
/* istanbul ignore if */
if (err)
return callback(err);
token.user = ce.clone(user);
delete token.user.token;
token._ = joola.common.uuid();
token.token = token._;
token.timestamp = timestamp.getTime();
token.last = timestamp.getTime();
token.expires = timestamp.setMilliseconds(timestamp.getMilliseconds() + (joola.config.get('authentication:tokens:expireafter') || 20 * 60 * 1000 /*20 minutes*/));
token.user = JSON.stringify(token.user);
/* istanbul ignore else */
if (joola.redis) {
return joola.redis.hmset(joola.config.namespace + ':auth:tokens:' + token._, token, function (err) {
if (err)
return callback(err);
joola.memory.set('token:' + token._, token, (joola.config.get('authentication:tokens:expireafter') || 20 * 60 * 1000 /*20 minutes*/));
token.user = JSON.parse(token.user);
joola.logger.trace({category: 'security'}, '[auth] generated token [' + token._ + '] for [' + token.user.username + '].');
auth.extendToken(token, function (err) {
return process.nextTick(function () {
if (typeof callback === 'function')
return callback(err, ce.clone(token));
});
});
});
}
else {
joola.memory.set('token:' + token._, token, (joola.config.get('authentication:tokens:expireafter') || 20 * 60 * 1000 /*20 minutes*/));
token.user = JSON.parse(token.user);
joola.logger.trace({category: 'security'}, '[auth] generated token [' + token._ + '] for [' + token.user.username + '].');
auth.extendToken(token, function (err) {
return process.nextTick(function () {
if (typeof callback === 'function')
return callback(err, ce.clone(token));
});
});
}
});
};
/**
* @param {string} token is a string with the token to validate.
* @param {Function} callback called following execution with errors and results.
* Validates that the requested token exists.
*
* The function calls on completion an optional `callback` with:
* - `err` if occured, an error object, else null.
* - `result` contains a {Object} with the validated Token.
*/
auth.validateToken = function (req, token, APIToken, callback) {
if (!callback) {
callback = APIToken;
APIToken = token;
token = req;
req = null;
}
callback = callback || function () {
};
if (!token && !APIToken)
return process.nextTick(function () {
return callback(new Error('Missing token for validation'));
});
if (APIToken) {
var cachedUser = joola.memory.get('APIToken:' + APIToken);
if (cachedUser) {
cachedUser.cached = true;
return callback(null, cachedUser);
}
joola.logger.trace({category: 'security'}, 'Verifying API Token [' + APIToken + ']');
joola.dispatch.users.verifyAPIToken({user: joola.SYSTEM_USER}, APIToken, function (err, user) {
/* istanbul ignore if */
if (err)
return process.nextTick(function () {
return callback(err);
});
//return auth.generateToken(user, callback);
joola.memory.set('APIToken:' + APIToken, user, (joola.config.get('authentication:tokens:expireafter') || 20 * 60 * 1000 /*20 minutes*/));
return callback(null, user);
});
}
else {
if (typeof token === 'object')
token = token._;
var cached = joola.memory.get('token:' + token);
if (cached) {
cached.cached = true;
return process.nextTick(function () {
return callback(null, cached);
});
}
//check that we have a valid store that doesn't allow anonymous
/* istanbul ignore if */
if (joola.config.get('authentication:store') === 'anonymous') {
var anonymousUser = {
username: 'anonymous',
roles: ['user']
};
return auth.generateToken(anonymousUser, callback);
}
//fetch the token
/* istanbul ignore else */
if (joola.redis) {
joola.redis.hgetall(joola.config.namespace + ':auth:tokens:' + token, function (err, _token) {
if (_token) {
//all is good
_token.user = JSON.parse(_token.user);
//extend the token
joola.memory.set('token:' + _token._, _token, (joola.config.get('authentication:tokens:expireafter') || 20 * 60 * 1000 /*20 minutes*/));
return auth.extendToken(req, _token, callback);
}
else
return process.nextTick(function () {
return callback(new AuthErrorTemplate('Failed to validate token [1] [' + token + ']'));
});
});
}
else {
var _token = joola.memory.get('token:' + token);
if (_token) {
//extend the token
joola.memory.set('token:' + _token._, _token, (joola.config.get('authentication:tokens:expireafter') || 20 * 60 * 1000 /*20 minutes*/));
return auth.extendToken(req, _token, callback);
}
else
return process.nextTick(function () {
return callback(new AuthErrorTemplate('Failed to validate token [2] [' + token + ']'));
});
}
}
};
/**
* @param {string} modulename is the name of the module to load.
* @param {string} action is the name of the action to run.
* @param {Function} callback called following execution with errors and results.
* Validates that the requested route exists and yields a valid action.
*
* The function calls on completion an optional `callback` with:
* - `err` if occured, an error object, else null.
* - `result` contains a {Object} with the action from the route.
*/
auth.validateRoute = function (req, modulename, action, callback) {
if (!callback) {
callback = action;
action = modulename;
modulename = req;
}
var cached = joola.memory.get('validateRoute:' + modulename + ':' + action);
if (cached)
return callback(null, cached);
var module;
if (!modulename)
return process.nextTick(function () {
return callback({code: 404, message: 'Not Found'});
});
try {
module = require('../dispatch/' + modulename);
}
catch (ex) {
joola.logger.trace({category: 'security'}, 'Failed to validate route for [' + modulename + '][' + action + ']');
return process.nextTick(function () {
return callback(ex);
});
}
if (!action)
action = 'index';
try {
var _action = module[action];
joola.logger.trace({category: 'security'}, 'Validated module for [' + modulename + '][' + action + ']');
if (!_action)
return process.nextTick(function () {
return callback({code: 404, message: 'Not Found'});
});
joola.memory.set('validateRoute:' + modulename + ':' + action, _action);
return process.nextTick(function () {
return callback(null, _action);
});
}
catch (err) {
return process.nextTick(function () {
return callback(err);
});
}
};
/**
* @param {Object} action contain the `action` we wish to validate.
* @param {Object} req holds the http request.
* @param {Object} res holds the http response.
* @param {Function} callback called following execution with errors and results.
* Validates the request has required parameters and that the user has permission to access the `action`.
* The `action` parameter holds an endpoint, for example `joola.dispatch.datasources.list`. The endpoint includes a list
* of allowed parameters and the actual list sent `req.params` is inspected. If a parameter exists in `req.params`, but not
* in the allow list, it is ignored and not passed along to the `action`.
* The next step is to validate permissions, for that the user is retrieved from `req.user` which was set earlier by `validateToken`.
* The endpoint holds a list of allowed `roles` and the existing user roles is compared against it.
* If all conditions are met, then the function calls a callback with a `true` result.
*
* The function calls on completion an optional `callback` with:
* - `err` if occured, an error object, else null.
* - `result` contains a {boolean} indicating if the action is validated.
*
* Configuration elements participating: `config:authentication`.
*
* ```js
* var action = joola.dispatch.datasources.list;
* //req is an http request with a valid token
* joola.auth.validateAction(action, req, res, function(err, bValidToken) {
* console.log(err, bValidToken);
* }
* ```
*/
auth.validateAction = function (action, req, res, callback) {
if (req.method == 'OPTIONS')
return process.nextTick(function () {
return callback(null, true);
});
if (!req.user)
return process.nextTick(function () {
return callback(new auth.AuthErrorTemplate('Request for action by unauthenticated user.'), false);
});
if (typeof req.user === 'string')
req.user = JSON.parse(req.user);
var cached = joola.memory.get('validateAction:' + action.name + ':' + req.user.username);
if (cached)
return callback(null, true);
var userPermissions = [];
if (req.user.roles) {
joola.workspaces.get({user: req.user}, req.user.workspace, function (err, workspace) {
/* istanbul ignore if */
if (err) {
joola.logger.warn({category: 'security'}, 'Missing role [' + req.user.roles.join(',') + '] for user ' + req.user.username + ', with workspace [' + req.user.workspace + ']');
return process.nextTick(function () {
return callback(new auth.AuthErrorTemplate('Missing permission to run this action. [action:' + action.name + '][permission:' + action._permission + '][roles:' + JSON.stringify(req.user.roles) + ', permissions: workspace missing: ' + err + ']'), false);
});
}
if (!Array.isArray(req.user.roles))
req.user.roles = [req.user.roles];
req.user.roles.forEach(function (role) {
Object.keys(workspace.roles).forEach(function (roleToCompareAgainst) {
if (role === roleToCompareAgainst) {
userPermissions = userPermissions.concat(workspace.roles[roleToCompareAgainst].permissions);
}
});
});
var hasPermission = false;
if (!Array.isArray(userPermissions))
userPermissions = [userPermissions];
action._permission.forEach(function (permission) {
if (userPermissions.indexOf(permission) > -1) {
hasPermission = true;
}
});
if (!hasPermission && action._permission != 'guest') {
return process.nextTick(function () {
return callback(new auth.AuthErrorTemplate('Missing permission to run this action. [action:' + action.name + '][permission:' + action._permission + '][roles:' + JSON.stringify(req.user.roles) + ', permissions: ' + userPermissions.join(',') + ']'), false);
});
}
if (action._permission === 'guest')
req.guestAllowedAction = true;
req.user.permissions = userPermissions;
joola.memory.set('validateAction:' + action.name + ':' + req.user.username, true);
return process.nextTick(function () {
return callback(null, true);
});
});
}
else
return process.nextTick(function () {
return callback(new auth.AuthErrorTemplate('Missing permission to run this action. [action:' + action.name + '][permission:' + action._permission + '][roles:' + JSON.stringify(req.user.roles) + ', permissions: no roles for user]'), false);
});
};
auth.checkRateLimits = function (req, res, callback) {
var limitKey;
if (!req.user) {
limitKey = req.connection.remoteAddress;
req.limits = {
limit: joola.config.get('authentication:ratelimits:guest'), //TODO: Set default from config
remaining: 0,
reset: 0
};
}
else {
limitKey = req.user.workspace + ':' + req.user.username;
if (!req.user.hasOwnProperty('ratelimit'))
req.user.ratelimit = parseInt(joola.config.get('authentication:ratelimits:user'));
else
req.user.ratelimit = parseInt(req.user.ratelimit);
//no rate limit applied
if (req.user.ratelimit === 0)
return callback(null);
req.limits = {
limit: req.user.ratelimit,
remaining: 0,
reset: 0
};
}
var redisKey = joola.config.namespace + ':ratelimits:' + limitKey;
/* istanbul ignore else */
if (joola.redis) {
joola.redis.incr(redisKey, function (err, result) {
/* istanbul ignore if */
if (err)
return callback(err);
if (result === 1)
joola.redis.pexpire(redisKey, 60 * 60 * 1000);
req.limits.remaining = req.limits.limit - result;
joola.redis.pttl(redisKey, function (err, ttl) {
/* istanbul ignore if */
if (err)
return callback(err);
req.limits.reset = Math.floor((new Date().getTime() + ttl ) / 1000);
res.header("X-RateLimit-Limit", req.limits.limit);
res.header("X-RateLimit-Remaining", req.limits.remaining < 0 ? 0 : req.limits.remaining);
res.header("X-RateLimit-Reset", req.limits.reset);
res.header("Retry-After", Math.floor(ttl / 1000));
if (req.limits.remaining < 0)
return callback(new Error('Limit exceeded'));
return callback(null);
});
});
}
else {
joola.memory.set(redisKey, parseInt(joola.memory.get(redisKey)) + 1);
var counter = joola.memory.get(redisKey);
if (counter === 1)
joola.memory.set(redisKey, counter, 60 * 60 * 1000);
//req.limits.remaining = req.limits.limit - result;
//req.limits.reset = Math.floor((new Date().getTime() + ttl ) / 1000);
res.header("X-RateLimit-Limit", req.limits.limit);
res.header("X-RateLimit-Remaining", req.limits.remaining < 0 ? 0 : req.limits.remaining);
res.header("X-RateLimit-Reset", 0);
res.header("Retry-After", 0);
if (req.limits.remaining < 0)
return callback(new Error('Limit exceeded'));
return callback(null);
}
};
/**
* @param {Object} req holds the http request.
* @param {Object} res holds the http response.
* @param {Function} next called upon completion.
* The actual middleware function that handles every incoming request for non-static resources and actions.
* The main logic flow is:
* - Check if action/resource is whitelisted
* - Try to locate a token for this request, if none show login/401
* - Validate the token
* - Validate the route
* - If all is well, run the request
*
* The function calls on completion an optional `next` with:
* - `err` if occured, an error object, else null.
*
* Configuration elements participating: `config:authentication`.
*
* Events raised via `dispatch`: `auth:login-request`, `auth:login-success`, `auth:login-fail`
*/
auth.middleware = function (req, res, next) {
var debug = {};
var parts = url.parse(req.url);
//TODO: Add header to SDK
//if (req.headers['content-type'] !== 'application/json' && (req.method !== 'GET' && req.method !== 'OPTIONS'))
// return router.responseError(415, new Error('Unsupported Media Type'), req, res);
if (req.method === 'POST') {
var obj;
try {
obj = Object.keys(req.body)[0];
obj = JSON.parse(obj);
}
catch (ex) {
if (req && req.body)
obj = req.body;
}
if (typeof obj !== 'object')
obj = {};
Object.keys(obj).forEach(function (key) {
var value = obj[key];
req.params[key] = value;
});
}
//allow static content to pass
if (auth.whitelist_extensions.indexOf(path.extname(parts.pathname)) > -1 && parts.pathname !== '/joola.js') {
return next();
}
joola.logger.trace({category: 'security'}, 'Authentication request [' + parts.pathname + ']');
//allow white-listed endpoints to pass through, for example /login
if (auth.whitelist_endpoints.indexOf(parts.pathname) > -1 || auth.whitelist_endpoints.indexOf('/' + parts.pathname) > -1) {
return next();
}
//check if we have a valid token as part of the request
//check query string for `token`
var token = req.query.token;
var APIToken = req.query.APIToken || req.headers['joola-apitoken'];
//check query string for `token`
if (!token && req.headers) {
token = req.headers['joola-token'];
}
if (req.headers && req.headers.authorization) {
var header = req.headers.authorization || '', // get the header
authType = header.split(/\s+/)[0],
headerToken = header.split(/\s+/).pop() || '', // and the encoded auth token
headerAuth = new Buffer(headerToken, 'base64').toString(), // convert from base64
authParts = headerAuth.split(/:/), // split on colon
workspace = authParts[0].split('/')[0],
username = authParts[0].split('/')[1],
password = authParts[1];
if (authType.toLowerCase() === 'basic') {
var context = {
user: {
username: 'bypass',
workspace: workspace
}
};
return joola.users.authenticate(context, workspace, username, password, function (err, user) {
/* istanbul ignore if */
if (err)
return router.responseError(404, err, req, res);
return auth.generateToken(user.user, function (err, _token) {
/* istanbul ignore if */
if (err)
return router.responseError(404, err, req, res);
req.headers.authorization = 'token ' + _token._;
return auth.middleware(req, res, next);
});
});
}
else if (authType.toLowerCase() === 'token') {
APIToken = headerToken;
}
}
var modulename = req.endpointRoute.module;
var action = req.endpointRoute.action;
//We have a token, let's validate
exports.validateToken(req, token, APIToken, function (err, _token) {
if (_token && _token._) {
//joola.stats.incr('auth:tokens:validated');
joola.logger.trace({category: 'security'}, 'Token [' + _token._ + '] is valid for user [' + _token.user.username + '].');
req.token = _token;
req.user = _token.user;
}
else if (_token && APIToken) {
joola.logger.trace({category: 'security'}, 'Token [' + APIToken + '] is valid for user [' + _token.username + '].');
req.token = APIToken;
req.user = _token;
}
//rate limits
exports.checkRateLimits(req, res, function (err) {
/* istanbul ignore if */
if (err) {
err.help_url = 'http://github.com/joola/joola/wiki/rate-limits';
return router.responseError(429, err, req, res);
}
//we need to validate the route.
// we check that the user has permissions to run the action.
if (modulename && action) {
exports.validateRoute(req, modulename, action, function (err, action) {
/* istanbul ignore if */
if (err)
return router.responseError(err.code || 401, err, req, res);
exports.validateAction(action, req, res, function (err, valid) {
/* istanbul ignore if */
if (err) {
if (typeof err === 'string')
err = new AuthErrorTemplate(err);
return router.responseError(err.code || 401, err, req, res);
}
if (valid) {
return next();
}
else {
//joola.stats.incr('auth:middleware_invalid');
return router.responseError(401, new Error('Failed to validate request'), req, res);
}
});
});
}
else {
return router.responseError(401, new Error('Failed to validate request'), req, res);
}
});
});
};
/**
* @function hashPassword
* @param {string} plainPassword contains a simple text, plain password for hashing
* @param {string} [salt] the salt to use for hashing
* @return {string} hashed password with the hash.
* Hashes a plain text password using MD5.
* - `plainPassword` is the plain password to hash
*
* The function returns on completion a hashed string.
*
* ```js
* var plainPassword = 'password'
* var hashed = joola.dispatch.users.hashPassword(plainPassword);
* console.log(plainPassword, hashed);
* ```
*/
auth.hashPassword = function (plainPassword, salt) {
if (!plainPassword)
return null;
try {
if (!salt)
salt = joola.common.uuid();
var hash = crypto.createHash('md5').update(plainPassword.toString()).digest('hex');
return salt + '$' + hash;
}
catch (ex) {
/* istanbul ignore next */
return null;
}
};
/**
* @function validatePassword
* @param {string} plainPassword contains a simple text, plain password for hashing
* @param {string} hash contains a hashed string for comparison
* @return {bool} true if match.
* Validates that a hash and plain password match
* - `plainPassword` is the plain password to hash
* - `hash` is the hashed password to verify
*
* The function returns on completion a bool indicating if the password and hash match.
*
* ```js
* var plainPassword = 'password'
* var hash = 'hashedPassword'
* var match = joola.dispatch.users.validatePassword(plainPassword, hash);
* console.log(match);
* ```
*/
auth.validatePassword = function (plainPassword, hash) {
if (!plainPassword || !hash)
return false;
try {
var salt = hash.substring(0, hash.indexOf('$'));
var comparison = auth.hashPassword(plainPassword, salt);
return comparison == hash;
}
catch (ex) {
/* istanbul ignore next */
return false;
}
};
/**
* @function getUserByToken
* @param {string} token contains the token to translate into user
* @param {Function} callback called following execution with errors and results.
* Validates that a hash and plain password match
* - `token` contains the token to translate into user
*
* The function calls on completion an optional `callback` with:
* - `err` if occured, an error object, else null.
* - `result` contains a {boolean} indicating if the action is validated.
*
* The function returns on completion the user object assosciated with the provided token.
*
* ```js
* var token = '12345';
* joola.dispatch.users.getUserByToken(token, function(err, user){
* console.log(user);
* });
* ```
*/
auth.getUserByToken = function (token, callback) {
callback = callback || function () {
};
if (!token)
return process.nextTick(function () {
return callback(new Error('Token not provided'));
});
joola.users.verifyAPIToken({user: joola.SYSTEM_USER}, token, function (err, user) {
if (!err)
return callback(null, user);
/* istanbul ignore else */
if (joola.redis) {
joola.redis.hgetall(joola.config.namespace + ':auth:tokens:' + token, function (err, _token) {
/* istanbul ignore if */
if (err)
return process.nextTick(function () {
return callback(err);
});
if (!_token)
return process.nextTick(function () {
return callback(new Error('Token not found'));
});
/* istanbul ignore if */
if (!_token.user)
return process.nextTick(function () {
return callback(new Error('Failed to translate user from token'));
});
try {
_token.user = JSON.parse(_token.user);
}
catch (ex) {
/* istanbul ignore next */
return process.nextTick(function () {
return callback(ex);
});
}
return process.nextTick(function () {
return callback(null, _token.user);
});
});
}
else {
var _token = joola.memory.get('token:' + token);
if (!_token)
return process.nextTick(function () {
return callback(new Error('Token not found'));
});
if (!_token.user)
return process.nextTick(function () {
return callback(new Error('Failed to translate user from token'));
});
return process.nextTick(function () {
return callback(null, _token.user);
});
}
});
};