diff --git a/CHANGELOG b/CHANGELOG index 39db73329654..6c6a107c4d7b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,14 @@ devel ----- +* Automatically extend web UI sessions while they are still active. + The web UI can now call a backend route to renew its JWT, so there will not + be any rude logouts in the middle of an active session. + + Active web UI sessions (here: sessions with user activity within the last + 90 minutes) will automatically renew their JWT if they get close to the + JWT expiry date. + * Reduce memory usage for in-memory revision trees. Previously, a revision tree instance for a non-empty collection/shard was using 4 MB of memory when uncompressed. Trees that were unused for a while were compressed on diff --git a/arangod/Auth/TokenCache.h b/arangod/Auth/TokenCache.h index 91d46beaf046..df6a4137cc04 100644 --- a/arangod/Auth/TokenCache.h +++ b/arangod/Auth/TokenCache.h @@ -63,6 +63,7 @@ class TokenCache { bool authenticated() const { return _authenticated; } void authenticated(bool value) { _authenticated = value; } void setExpiry(double expiry) { _expiry = expiry; } + double expiry() const noexcept { return _expiry; } bool expired() const { return _expiry != 0 && _expiry < TRI_microtime(); } std::vector const& allowedPaths() const { return _allowedPaths; } diff --git a/arangod/Auth/UserManager.cpp b/arangod/Auth/UserManager.cpp index 1dff43256bf2..993ed7c43586 100644 --- a/arangod/Auth/UserManager.cpp +++ b/arangod/Auth/UserManager.cpp @@ -85,7 +85,9 @@ static bool inline IsRole(std::string const& name) { #ifndef USE_ENTERPRISE auth::UserManager::UserManager(application_features::ApplicationServer& server) - : _server(server), _globalVersion(1), _internalVersion(0) {} + : _server(server), + _globalVersion(1), + _internalVersion(0) {} #else auth::UserManager::UserManager(application_features::ApplicationServer& server) : _server(server), diff --git a/arangod/Auth/UserManager.h b/arangod/Auth/UserManager.h index a54b19bac331..e421eb5e5f87 100644 --- a/arangod/Auth/UserManager.h +++ b/arangod/Auth/UserManager.h @@ -184,4 +184,3 @@ class UserManager { }; } // namespace auth } // namespace arangodb - diff --git a/arangod/GeneralServer/AuthenticationFeature.cpp b/arangod/GeneralServer/AuthenticationFeature.cpp index f1b1860695e6..c2b8fd85b59c 100644 --- a/arangod/GeneralServer/AuthenticationFeature.cpp +++ b/arangod/GeneralServer/AuthenticationFeature.cpp @@ -191,22 +191,22 @@ void AuthenticationFeature::prepare() { if (ServerState::isSingleServer(role) || ServerState::isCoordinator(role)) { #if USE_ENTERPRISE if (server().getFeature().isEnabled()) { - _userManager.reset( - new auth::UserManager(server(), std::make_unique( - server().getFeature()))); - } else { - _userManager.reset(new auth::UserManager(server())); + _userManager = std::make_unique( + server(), std::make_unique(server().getFeature())); } -#else - _userManager.reset(new auth::UserManager(server())); #endif + if (_userManager == nullptr) { + _userManager = std::make_unique(server()); + } + + TRI_ASSERT(_userManager != nullptr); } else { LOG_TOPIC("713c0", DEBUG, Logger::AUTHENTICATION) << "Not creating user manager"; } TRI_ASSERT(_authCache == nullptr); - _authCache.reset(new auth::TokenCache(_userManager.get(), _authenticationTimeout)); + _authCache = std::make_unique(_userManager.get(), _authenticationTimeout); if (_jwtSecretProgramOption.empty()) { LOG_TOPIC("43396", INFO, Logger::AUTHENTICATION) diff --git a/arangod/GeneralServer/CommTask.cpp b/arangod/GeneralServer/CommTask.cpp index d922af0e88cd..d7357aaa2bad 100644 --- a/arangod/GeneralServer/CommTask.cpp +++ b/arangod/GeneralServer/CommTask.cpp @@ -737,13 +737,13 @@ auth::TokenCache::Entry CommTask::checkAuthHeader(GeneralRequest& req) { return auth::TokenCache::Entry::Superuser(); } - std::string::size_type methodPos = authStr.find_first_of(' '); + std::string::size_type methodPos = authStr.find(' '); if (methodPos == std::string::npos) { events::UnknownAuthenticationMethod(req); return auth::TokenCache::Entry::Unauthenticated(); } - // skip over authentication method + // skip over authentication method and following whitespace char const* auth = authStr.c_str() + methodPos; while (*auth == ' ') { ++auth; @@ -770,6 +770,7 @@ auth::TokenCache::Entry CommTask::checkAuthHeader(GeneralRequest& req) { auto authToken = this->_auth->tokenCache().checkAuthentication(authMethod, auth); req.setAuthenticated(authToken.authenticated()); + req.setTokenExpiry(authToken.expiry()); req.setUser(authToken.username()); // do copy here, so that we do not invalidate the member if (authToken.authenticated()) { events::Authenticated(req, authMethod); diff --git a/arangod/RestHandler/RestAuthHandler.cpp b/arangod/RestHandler/RestAuthHandler.cpp index 97d0ca184c69..7cd451f5a084 100644 --- a/arangod/RestHandler/RestAuthHandler.cpp +++ b/arangod/RestHandler/RestAuthHandler.cpp @@ -49,6 +49,40 @@ RestStatus RestAuthHandler::execute() { generateError(rest::ResponseCode::METHOD_NOT_ALLOWED, TRI_ERROR_HTTP_METHOD_NOT_ALLOWED); return RestStatus::DONE; } + + auth::UserManager* um = AuthenticationFeature::instance()->userManager(); + if (um == nullptr) { + std::string msg = "This server does not support users"; + LOG_TOPIC("2e7d4", WARN, Logger::AUTHENTICATION) << msg; + generateError(rest::ResponseCode::UNAUTHORIZED, TRI_ERROR_HTTP_UNAUTHORIZED, msg); + return RestStatus::DONE; + } + + auto const& suffixes = _request->suffixes(); + + if (suffixes.size() == 1 && suffixes[0] == "renew") { + // JWT token renew request + if (!_request->authenticated() || + _request->user().empty() || + _request->authenticationMethod() != arangodb::rest::AuthenticationMethod::JWT) { + generateError(rest::ResponseCode::NOT_FOUND, TRI_ERROR_USER_NOT_FOUND); + } else { + VPackBuilder resultBuilder; + { + VPackObjectBuilder b(&resultBuilder); + // only return a new token if the current token is about to expire + if (_request->tokenExpiry() > 0.0 && + _request->tokenExpiry() - TRI_microtime() < 150.0) { + resultBuilder.add("jwt", VPackValue(generateJwt(_request->user()))); + } + // otherwise we will send an empty body back. callers must handle + // this case! + } + + generateDocument(resultBuilder.slice(), true, &VPackOptions::Defaults); + } + return RestStatus::DONE; + } bool parseSuccess = false; VPackSlice slice = this->parseVPackBody(parseSuccess); @@ -84,12 +118,7 @@ RestStatus RestAuthHandler::execute() { } }); - auth::UserManager* um = AuthenticationFeature::instance()->userManager(); - if (um == nullptr) { - std::string msg = "This server does not support users"; - LOG_TOPIC("2e7d4", ERR, Logger::AUTHENTICATION) << msg; - generateError(rest::ResponseCode::UNAUTHORIZED, TRI_ERROR_HTTP_UNAUTHORIZED, msg); - } else if (um->checkPassword(username, password)) { + if (um->checkPassword(username, password)) { VPackBuilder resultBuilder; { VPackObjectBuilder b(&resultBuilder); diff --git a/arangod/V8Server/v8-vocbase.cpp b/arangod/V8Server/v8-vocbase.cpp index 51e4cf1f0194..fb0944f91384 100644 --- a/arangod/V8Server/v8-vocbase.cpp +++ b/arangod/V8Server/v8-vocbase.cpp @@ -2272,13 +2272,20 @@ void TRI_InitV8VocBridge(v8::Isolate* isolate, v8::Handle context, vocbase.server().getFeature().maxNumberOfShards()), v8::PropertyAttribute(v8::ReadOnly | v8::DontEnum)) .FromMaybe(false); // ignore result - // max number of shards + // force one shard collections? context->Global() ->DefineOwnProperty(TRI_IGETC, TRI_V8_ASCII_STRING(isolate, "FORCE_ONE_SHARD"), v8::Boolean::New(isolate, vocbase.server().getFeature().forceOneShard()), v8::PropertyAttribute(v8::ReadOnly | v8::DontEnum)) .FromMaybe(false); // ignore result + + // session timeout (used by web UI) + context->Global() + ->DefineOwnProperty(TRI_IGETC, + TRI_V8_ASCII_STRING(isolate, "SESSION_TIMEOUT"), + v8::Number::New(isolate, static_cast(AuthenticationFeature::instance()->sessionTimeout())), v8::PropertyAttribute(v8::ReadOnly | v8::DontEnum)) + .FromMaybe(false); // ignore result // a thread-global variable that will contain the AQL module. // do not remove this, otherwise AQL queries will break diff --git a/js/apps/system/_admin/aardvark/APP/aardvark.js b/js/apps/system/_admin/aardvark/APP/aardvark.js index 964ebc69153c..a78aad461d01 100644 --- a/js/apps/system/_admin/aardvark/APP/aardvark.js +++ b/js/apps/system/_admin/aardvark/APP/aardvark.js @@ -96,6 +96,7 @@ router.get('/config.js', function (req, res) { defaultReplicationFactor: internal.defaultReplicationFactor, maxNumberOfShards: internal.maxNumberOfShards, forceOneShard: internal.forceOneShard, + sessionTimeout: internal.sessionTimeout, showMaintenanceStatus: true })}` ); diff --git a/js/apps/system/_admin/aardvark/APP/frontend/js/arango/arango.js b/js/apps/system/_admin/aardvark/APP/frontend/js/arango/arango.js index 96ba503eba39..d63c91b5676f 100644 --- a/js/apps/system/_admin/aardvark/APP/frontend/js/arango/arango.js +++ b/js/apps/system/_admin/aardvark/APP/frontend/js/arango/arango.js @@ -1,5 +1,5 @@ /* jshint unused: false */ -/* global Noty, Blob, window, Joi, sigma, $, tippy, document, _, arangoHelper, frontendConfig, arangoHelper, sessionStorage, localStorage, XMLHttpRequest */ +/* global Noty, Blob, window, atob, Joi, sigma, $, tippy, document, _, arangoHelper, frontendConfig, sessionStorage, localStorage, XMLHttpRequest */ (function () { 'use strict'; @@ -124,6 +124,65 @@ }); }, + lastActivity: function () { + // return timestamp of last activity (only seconds part) + return sessionStorage.getItem('lastActivity') || 0; + }, + + noteActivity: function () { + // note timestamp of last activity (only seconds part) + sessionStorage.setItem('lastActivity', Date.now() / 1000); + }, + + renewJwt: function (callback) { + if (!window.atob) { + return; + } + var self = this; + var currentUser = self.getCurrentJwtUsername(); + if (currentUser === undefined || currentUser === "") { + return; + } + + $.ajax({ + cache: false, + type: 'POST', + url: self.databaseUrl('/_open/auth/renew'), + data: JSON.stringify({ username: currentUser }), + contentType: 'application/json', + processData: false, + success: function (data) { + var updated = false; + if (data.jwt) { + try { + var jwtParts = data.jwt.split('.'); + if (!jwtParts[1]) { + throw "invalid token!"; + } + var payload = JSON.parse(atob(jwtParts[1])); + if (payload.preferred_username === currentUser) { + self.setCurrentJwt(data.jwt, currentUser); + updated = true; + } + } catch (err) { + } + } + if (updated) { + // success + callback(); + } + }, + error: function (data) { + // this function is triggered by a non-interactive + // background task. if it fails for whatever reason, + // we don't report this error. + // the worst thing that can happen is that the JWT + // is not renewed and thus the user eventually gets + // logged out + } + }); + }, + getCoordinatorShortName: function (id) { var shortName; if (window.clusterHealth) { diff --git a/js/apps/system/_admin/aardvark/APP/frontend/js/models/arangoUsers.js b/js/apps/system/_admin/aardvark/APP/frontend/js/models/arangoUsers.js index 1b171852ce15..6c265c91e47f 100644 --- a/js/apps/system/_admin/aardvark/APP/frontend/js/models/arangoUsers.js +++ b/js/apps/system/_admin/aardvark/APP/frontend/js/models/arangoUsers.js @@ -19,32 +19,12 @@ window.Users = Backbone.Model.extend({ }, url: function () { - if (this.isNew()) { - return arangoHelper.databaseUrl('/_api/user'); - } - if (this.get('user') !== '') { + if (!this.isNew() && this.get('user') !== '') { return arangoHelper.databaseUrl('/_api/user/' + encodeURIComponent(this.get('user'))); } return arangoHelper.databaseUrl('/_api/user'); }, - - checkPassword: function (passwd, callback) { - $.ajax({ - cache: false, - type: 'POST', - url: arangoHelper.databaseUrl('/_api/user/' + encodeURIComponent(this.get('user'))), - data: JSON.stringify({ passwd: passwd }), - contentType: 'application/json', - processData: false, - success: function (data) { - callback(false, data); - }, - error: function (data) { - callback(true, data); - } - }); - }, - + setPassword: function (passwd) { $.ajax({ cache: false, diff --git a/js/apps/system/_admin/aardvark/APP/frontend/js/routers/startApp.js b/js/apps/system/_admin/aardvark/APP/frontend/js/routers/startApp.js index 026cd762a264..486757eb8325 100644 --- a/js/apps/system/_admin/aardvark/APP/frontend/js/routers/startApp.js +++ b/js/apps/system/_admin/aardvark/APP/frontend/js/routers/startApp.js @@ -28,7 +28,7 @@ window.App.handleResize(); }); - // create only this one global event listener + // create only the following global event listeners $(document).click(function (e) { e.stopPropagation(); @@ -41,14 +41,21 @@ $('.subBarDropdown').hide(); } } + + // also note that the web UI was actively used + arangoHelper.noteActivity(); }); $('body').on('keyup', function (e) { + // hide modal dialogs when pressing ESC if (e.keyCode === 27) { if (window.modalView) { window.modalView.hide(); } } + + // also note that the web UI was actively used + arangoHelper.noteActivity(); }); } }()); diff --git a/js/apps/system/_admin/aardvark/APP/frontend/js/views/footerView.js b/js/apps/system/_admin/aardvark/APP/frontend/js/views/footerView.js index 2dbd956c245f..2b7362b10e37 100644 --- a/js/apps/system/_admin/aardvark/APP/frontend/js/views/footerView.js +++ b/js/apps/system/_admin/aardvark/APP/frontend/js/views/footerView.js @@ -13,6 +13,10 @@ timer: 15000, lap: 0, timerFunction: null, + // last time of JWT renewal call, in seconds. + // this is initialized to the current time so we don't + // fire off a renewal request at the very beginning. + lastTokenRenewal: Date.now() / 1000, events: { 'click .footer-center p': 'showShortcutModal' @@ -43,6 +47,39 @@ }); } }, 1000); + + // track an activity once when we initialize this view + arangoHelper.noteActivity(); + + window.setInterval(function () { + if (self.isOffline) { + // only try to renew token if we are still online + return; + } + + var now = Date.now() / 1000; + var lastActivity = arangoHelper.lastActivity(); + + if (lastActivity > 0 && (now - lastActivity) > 90 * 60) { + // don't make an attempt to renew the token if last + // user activity is longer than 90 minutes ago + return; + } + + // to save some superfluous HTTP requests to the server, + // try to renew only if session time is x% or more over + var frac = (frontendConfig.sessionTimeout >= 1800) ? 0.95 : 0.8; + if (now - self.lastTokenRenewal < frontendConfig.sessionTimeout * frac) { + return; + } + + arangoHelper.renewJwt(function() { + // successful renewal of token. now store last renewal time so + // that we later only renew if the session is again about to + // expire + self.lastTokenRenewal = now; + }); + }, 15 * 1000); }, template: templateEngine.createTemplate('footerView.ejs'), diff --git a/js/apps/system/_admin/aardvark/APP/frontend/js/views/navigationView.js b/js/apps/system/_admin/aardvark/APP/frontend/js/views/navigationView.js index ca4e3b5e4164..cd8e138f8888 100644 --- a/js/apps/system/_admin/aardvark/APP/frontend/js/views/navigationView.js +++ b/js/apps/system/_admin/aardvark/APP/frontend/js/views/navigationView.js @@ -296,18 +296,6 @@ } }, - /* - breadcrumb: function (name) { - - if (window.location.hash.split('/')[0] !== '#collection') { - $('#subNavigationBar .breadcrumb').html( - '' + name + '' - ) - } - - }, - */ - selectMenuItem: function (menuItem, noMenuEntry) { if (menuItem === undefined) { menuItem = window.location.hash.split('/')[0]; diff --git a/js/server/bootstrap/modules/internal.js b/js/server/bootstrap/modules/internal.js index 738fb58438eb..28700974a708 100644 --- a/js/server/bootstrap/modules/internal.js +++ b/js/server/bootstrap/modules/internal.js @@ -542,6 +542,12 @@ delete global.FORCE_ONE_SHARD; } + // server session timeout (for web UI) + if (global.SESSION_TIMEOUT) { + exports.sessionTimeout = global.SESSION_TIMEOUT; + delete global.SESSION_TIMEOUT; + } + // ///////////////////////////////////////////////////////////////////////////// // / @brief whether or not clustering is enabled // ///////////////////////////////////////////////////////////////////////////// diff --git a/lib/Logger/LogMacros.h b/lib/Logger/LogMacros.h index 928c5ff6da5e..16acea335ca8 100644 --- a/lib/Logger/LogMacros.h +++ b/lib/Logger/LogMacros.h @@ -64,7 +64,7 @@ << ::arangodb::Logger::LOGID((id)) /// @brief logs a message for a topic given that a condition is true -#if ARANGODB_ENABLE_MAINTAINER_MODE +#ifdef ARANGODB_ENABLE_MAINTAINER_MODE // in maintainer mode, we *intentionally and always build all log messages* if // the condition is true. // we do this to find any errors when building log messages in low log levels diff --git a/lib/Rest/GeneralRequest.h b/lib/Rest/GeneralRequest.h index 8ea20f492bfd..820255541bb1 100644 --- a/lib/Rest/GeneralRequest.h +++ b/lib/Rest/GeneralRequest.h @@ -92,6 +92,7 @@ class GeneralRequest { : _connectionInfo(connectionInfo), _messageId(mid), _requestContext(nullptr), + _tokenExpiry(0.0), _authenticationMethod(rest::AuthenticationMethod::NONE), _type(RequestType::ILLEGAL), _contentType(ContentType::UNSET), @@ -117,6 +118,9 @@ class GeneralRequest { bool authenticated() const { return _authenticated; } void setAuthenticated(bool a) { _authenticated = a; } + double tokenExpiry() const { return _tokenExpiry; } + void setTokenExpiry(double value) { _tokenExpiry = value; } + // @brief User sending this request std::string const& user() const { return _user; } void setUser(std::string const& user) { _user = user; } @@ -252,6 +256,8 @@ class GeneralRequest { // request context (might contain vocbase) RequestContext* _requestContext; + + double _tokenExpiry; rest::AuthenticationMethod _authenticationMethod;