diff --git a/Documentation/Books/Users/Foxx/FoxxSessions.mdpp b/Documentation/Books/Users/Foxx/FoxxSessions.mdpp new file mode 100644 index 0000000000..e867e28e27 --- /dev/null +++ b/Documentation/Books/Users/Foxx/FoxxSessions.mdpp @@ -0,0 +1,54 @@ +!CHAPTER Foxx Sessions + +Foxx provides some convenience methods to make working with sessions easier. + +!SUBSECTION Activate sessions + +Enables session features for the controller. + +`controller.activateSessions(options)` + +Once sessions have been activated, a *session* property will be added to the *request* object passed to route handlers defined on the controller, which will be a saved instance of the session model provided by the session storage. + +If the option *autoCreateSession* has not explicitly been set to *false*, a new session will be created for users that do not yet have an active session. + +If *type* is set to *"cookie"*, the session cookie will be updated after every route. + +*Parameter* + +* *options* (optional): an object with any of the following properties: + * *sessionStorageApp* (optional): mount point of the session storage app to use. Default: *"/_system/sessions"*. + * *type* (optional): sessions type, currently only *"cookie"* is supported. Default: *"cookie"*. + * *cookieName* (optional): name of the session cookie if using cookie sessions. If a *cookieSecret* is provided, the signature will be stored in a cookie named *cookieName + "_sig"*. Defaults to *"sid"*. + * *cookieSecret* (optional): secret string to sign session cookies with if using cookie sessions. + * *autoCreateSession* (optional): whether a session should always be created if none exists. Default: *true*. + +@EXAMPLES + +```js +var controller = new FoxxController(applicationContext); +controller.activateSessions({ + sessionStorageApp: '/_system/sessions', + cookieName: 'sid', + cookieSecret: 'secret', + type: 'cookie' +}); +``` + +!SUBSECTION Define a session destruction route + +Defines a route that will destroy the session. + +`controller.destroySession(path, options)` + +Defines a route handler on the controller that destroys the session. + +When using cookie sessions, this function will clear the session cookie (if *autoCreateSession* was disabled) or create a new session cookie, before calling the *after* function. + +*Parameter* + +* *path*: route path as passed to *controller.get*, *controller.post*, etc. +* *options* (optional): an object with any of the following properties: + * *method* (optional): HTTP method to handle. Default: *"post"*. + * *before* (optional): function to execute before the session is destroyed. Receives the same arguments as a regular route handler. + * *after* (optional): function to execute after the session is destroyed. Receives the same arguments as a regular route handler. Default: a function that sends a *{"message": "logged out"}* JSON response. diff --git a/Documentation/Books/Users/FoxxBundledApps/README.mdpp b/Documentation/Books/Users/FoxxBundledApps/README.mdpp new file mode 100644 index 0000000000..16f20ff67d --- /dev/null +++ b/Documentation/Books/Users/FoxxBundledApps/README.mdpp @@ -0,0 +1,3 @@ +!CHAPTER Bundled Foxx Applications + +ArangoDB ships with a number of Foxx apps that allow you to implement basic authentication and user management with minimal effort. \ No newline at end of file diff --git a/Documentation/Books/Users/FoxxBundledApps/Sessions.mdpp b/Documentation/Books/Users/FoxxBundledApps/Sessions.mdpp new file mode 100644 index 0000000000..88b014f048 --- /dev/null +++ b/Documentation/Books/Users/FoxxBundledApps/Sessions.mdpp @@ -0,0 +1,273 @@ +!CHAPTER The Sessions App + +The sessions app provides a session storage JavaScript API that can be used in other Foxx apps. + +!SECTION Configuration + +This app has the following configuration options: + +* *timeToLive* (optional): number of milliseconds until the session expires. Default: *604800000* (one week). +* *ttlType* (optional): attribute against which the *timeToLive* is enforced. Valid options: *lastAccess*, *lastUpdate*, *created*. Default: *"created"*. +* *sidTimestamp* (optional): whether to append a timestamp to the random part of generated session IDs. Default: *false*. +* *sidLength* (optional): number of random characters to use for new session IDs. Default *20*. + +!SECTION JavaScript API: *sessionStorage* + +This app exposes a session storage via a JavaScript API named *sessionStorage*. + +@EXAMPLES + +```js +var sessionStorage = Foxx.requireApp('/_system/sessions').sessionStorage; +``` + +!SUBSECTION Exceptions + +!SUBSUBSECTION Session Not Found + +Indicates a session could not be found in the database. + +`new sessionStorage.errors.SessionNotFound(sessionId)` + +Thrown by the session storage's *delete* and *get* methods if passed a session ID that does not exist in the database. + +@EXAMPLES + +```js +try { + sessionStorage.get(invalidSessionId); +} catch(err) { + assertTrue(err instanceof sessionStorage.errors.SessionNotFound); +} +``` + +!SUBSUBSECTION Session Expired + +Indicates the session exists in the database but has expired. + +`new sessionStorage.errors.SessionExpired(sessionId)` + +Thrown by the session storage's *get* method if passed a session ID for a session that has expired. See also this app's configuration options. + +@EXAMPLES + +```js +try { + sessionStorage.get(expiredSessionId); +} catch(err) { + assertTrue(err instanceof sessionStorage.errors.SessionExpired); + assertTrue(err instanceof sessionStorage.errors.SessionNotFound); +} +``` + +!SUBSECTION The session object + +Session objects are instances of a Foxx model with the following attributes: + +* *sessionData*: volatile session data. This can be an arbitrary object that will be stored with the session in the database. If you want to store session-specific (rather than user-specific) data in the database, this is the right place for that. +* *uid*: the session's active user's *_key* or *undefined* (no active user). +* *userData*: the session's active user's *userData* attribute or an empty object. +* *created*: timestamp the session was created at. +* *lastAccess*: timestamp of the last time the session was fetched from the database. +* *lastUpdate*: timestamp of the last time the session was written to the database. + +!SUBSECTION Create a session + +Creates and saves a new instance of the session model. + +`sessionStorage.create(sessionData)` + +*Parameter* + +* *sessionData* (optional): an arbitrary object that will be stored as the session's *sessionData* attribute when the model is saved to the database. + +@EXAMPLES + +```js +var session = sessionStorage.create(sessionData); +assertEqual(session.get('sessionData'), sessionData); +``` + +!SUBSECTION Fetch an existing session + +There are two ways to fetch a session via the session storage API: + +* resolving a session cookie with the *fromCookie* method +* calling the session storage's *get* method with a session ID directly + +!SUBSUBSECTION Resolve a session cookie + +Fetch a session matching a cookie in a Foxx request. + +`sessionStorage.fromCookie(request, cookieName, secret)` + +Parses a request's cookies and returns the matching instance of the session model. + +The method will return *null* instead of a session object in the following cases: + +* the request has no session cookie +* the request's session cookie does not match a known session ID +* the matching session has expired +* the cookie's signature is missing (if a *secret* is provided) +* the cookie's signature does not match (if a *secret* is provided) + +*Parameter* + +* *request*: a Foxx request object as passed to controller routes. +* *cookieName*: name of the cookie to parse. +* *secret* (optional): secret string to validate the cookie's signature with. + +@EXAMPLES + +```js +controller.get('/hello', function(request, response) { + var session = sessionStorage.fromCookie(request, cookieName, secret); + response.json(session.get('sessionData')); +}); +``` + +!SUBSUBSECTION Resolve a session ID directly + +Fetch a session from the database for a given ID. + +`sessionStorage.get(sessionId)` + +Attempts to load the session with the given session ID from the database. If the session does not exist, a *SessionNotFound* exception will be thrown. If the session does exist, but has already expired, a *SessionExpired* exception will be thrown instead. + +*Parameter* + +* *sessionId*: a session *_key*. + +@EXAMPLES + +```js +var session = sessionStorage.get(sessionId); +``` + +!SUBSECTION Delete a session + +There are two ways to delete a session from the database: + +* calling the session storage's *delete* method with a session ID directly +* telling a session to delete itself + +!SUBSUBSECTION Delete a session by its ID + +Delete a session with a given ID. + +`sessionStorage.delete(sessionId)` + +Attempts to delete the session with the given session ID from the database. If the session does not exist, a *SessionNotFound* exception will be thrown. The method always returns *null*. + +*Parameter* + +* *sessionId*: a session *_key*. + +@EXAMPLES + +```js +sessionStorage.delete(sessionId); +``` + +!SUBSUBSECTION Tell a session to delete itself + +Delete a session from the database. + +`session.delete()` + +Attempts to delete the session from the database. + +Returns *true* if the session was deleted successfully. + +Returns *false* if the session already didn't exist. + +@EXAMPLES + +```js +session.delete(); +``` + +!SUBSECTION Save a session + +Save a session to the database. + +`session.save()` + +If you made any changes to the session and are not using the sessions app via Foxx Authentication, you must call this method to commit the changes to the database. + +@EXAMPLES + +```js +session.setUser(user); +session.save(); +``` + +!SUBSECTION Set a session's active user + +Set the active user of a session. + +`session.setUser(user)` + +Expects a Foxx model with a *userData* attribute and sets the session's *uid* attribute to the model's *_key* and the session's *userData* attribute to the model's *userData* attribute. + +*Parameter* + +* *user*: instance of a Foxx model with a *`userData* attribute. + +@EXAMPLES + +```js +session.setUser(user); +assertEqual(session.get('uid'), user.get('_key')); +assertEqual(session.get('userData'), user.get('userData')); +``` + +!SUBSECTION Add a session cookie to a response + +Add a session cookie to a Foxx response. + +`session.addCookie(response, cookieName, secret)` + +Adds a session cookie to the response. + +If a *secret* string is provided, the cookie is signed using that secret (a second cookie with the name *cookieName + '_sig'* containing the cryptographic signature of the cookie value is added to the response). + +If you want to use signed cookies, you must make sure to pass the same *secret* to the *fromCookie* method when fetching the session from a cookie later. + +*Parameter* + +* *response*: a Foxx response object as passed to controller routes. +* *cookieName*: name of the cookie to parse. +* *secret* (optional): secret string to sign the cookie with. + +@EXAMPLES + +```js +controller.get('/hello', function(request, response) { + session.addCookie(response, cookieName, secret); +}); +``` + +!SUBSECTION Clear a session cookie + +Clear the session cookie of a Foxx response. + +`session.clearCookie(response, cookieName, secret)` + +Adds a blank expired cookie to clear the user's previously set session cookie. + +If the method is passed a *secret* string, a second blank expired cookie is added that overwrites the signature cookie (see above). + +*Parameter* + +* *response*: a Foxx response object as passed to controller routes. +* *cookieName*: name of the cookie to parse. +* *secret* (optional): indicates the signature should be cleared also. + +@EXAMPLES + +```js +controller.get('/goodbye', function(request, response) { + session.clearCookie(response, cookieName, secret); +}); +``` \ No newline at end of file diff --git a/Documentation/Books/Users/FoxxBundledApps/SimpleAuth.mdpp b/Documentation/Books/Users/FoxxBundledApps/SimpleAuth.mdpp new file mode 100644 index 0000000000..0191ac2b58 --- /dev/null +++ b/Documentation/Books/Users/FoxxBundledApps/SimpleAuth.mdpp @@ -0,0 +1,49 @@ +!CHAPTER The Simple Authentication App + +The simple auth app provides hashed password-based authentication with automatically generated salts and constant-time password verification. + +!SECTION Configuration + +This app has the following configuration options: + +* *saltLength* (optional): length of newly generated salts. Default: *16*. +* *hashMethod* (optional): hash algorithm to use. Supported values: *sha1*, *sha224*, *sha256*, *md5*. Default: *"sha256"*. + +!SECTION JavaScript API: *auth* + +This app exposes its functionality via a JavaScript API named *auth*. + +@EXAMPLES + +```js +var auth = Foxx.requireApp('/_system/simple-auth').auth; +``` + +!SUBSECTION Generate an authentication object + +Generates an authentication object for a given password. + +`auth.hashPassword(password)` + +Returns an authentication object with the following properties: + +* *hash*: the generated hex-encoded hash. +* *salt*: the salt used to generate the hash. +* *method*: the algorithm used to generate the hash. + +*Parameter* + +* *password*: the password to hash. + +!SUBSECTION Verify a password + +Verifies a password against a given authentication object. + +`auth.verifyPassword(authData, password)` + +Generates a hash for the given password using the *salt* and *method* stored in the authentication object and performs a constant time string comparison on them. Returns *true* if the password is valid or *false* otherwise. + +*Parameter* + +* *authData*: an authentication object. +* *password*: a password to verify. \ No newline at end of file diff --git a/Documentation/Books/Users/FoxxBundledApps/Users.mdpp b/Documentation/Books/Users/FoxxBundledApps/Users.mdpp new file mode 100644 index 0000000000..bda7a3c44a --- /dev/null +++ b/Documentation/Books/Users/FoxxBundledApps/Users.mdpp @@ -0,0 +1,184 @@ +!CHAPTER The Users App + +The users app provides a username-based user storage JavaScript API that can be used in other Foxx apps. + +!SECTION JavaScript API: *userStorage* + +This app exposes a user storage via a JavaScript API named *userStorage*. + +@EXAMPLES + +```js +var userStorage = Foxx.requireApp('/_system/users').userStorage; +``` + +!SUBSECTION Exceptions + +!SUBSUBSECTION User Not Found + +Indicates a user could not be found in the database. + +`new userStorage.errors.UserNotFound(userId)` + +Thrown by the user storage's *delete* and *get* methods if passed a user ID that does not exist in the database. + +@EXAMPLES + +```js +try { + userStorage.get(invalidUserId); +} catch(err) { + assertTrue(err instanceof userStorage.errors.UserNotFound); +} +``` + +!SUBSUBSECTION Username Not Available + +Indicates a username is already in use. + +`new userStorage.errors.UsernameNotAvailable(username)` + +Thrown by the user storage's *create* method if passed a *userData* object with a *username* that is already in use. + +@EXAMPLES + +```js +try { + userStorage.create('alreadyTaken', {some: 'data'}); +} catch(err) { + assertTrue(err instanceof userStorage.errors.UsernameNotAvailable); +} +``` + +!SUBSECTION The user object + +User objects are instances of a Foxx model with the following attributes: + +* *user*: the user's unique *username*. +* *userData*: application-specific user data. +* *authData*: an arbitrary object used by authentication apps to store sensitive data. For password authentication this could be a hash, for third-party authentication services this could be information about the user's identity. This attribute should never be exposed to the user directly. + +!SUBSECTION Create a user + +Creates and saves a new instance of the user model. + +`userStorage.create(username, userData)` + +Throws *UsernameNotAvailable* if a user with the given username already exists. + +*Parameter* + +* *username*: an arbitrary string that will be used as the user's username +* *userData*: an arbitrary object that will be stored as the user's *userData* attribute when the model is saved to the database. + +@EXAMPLES + +```js +var user = userStorage.create('malaclypse', {hair: 'fuzzy'}); +assertEqual(user.get('userData').hair, 'fuzzy'); +``` + +!SUBSECTION Fetch an existing user + +There are two ways to fetch a user via the user storage API: + +* resolving a *username* with the user storage's *resolve* method +* calling the user storage's *get* method with a user ID directly + +!SUBSUBSECTION Resolve a *username* + +Fetches a user with a given *username*. + +`userStorage.resolve(username)` + +If the username can not be resolved, a *UserNotFound* exception will be thrown instead. + +*Parameter* + +* *username*: an arbitrary string matching the username of the user you are trying to fetch. + +@EXAMPLES + +```js +var user = userStorage.resolve('malaclypse'); +assertEqual(user.user, 'malaclypse'); +``` + +!SUBSUBSECTION Resolve a user ID directly + +Fetches a user with a given ID. + +`userStorage.get(userId)` + +Attempts to fetch the user with the given ID from the database. If the user does not exist, a *UserNotFound* exception will be thrown instead. + +*Parameter* + +* *userId*: a user *_key*. + +@EXAMPLES + +```js +var user = userStorage.get(userId); +assertEqual(user.get('_key'), userId); +``` + +!SUBSECTION Delete a user + +There are two ways to delete a user from the database: + +* calling the user storage's *delete* method with a user ID directly +* telling a user to delete itself + +!SUBSUBSECTION Delete a user by its ID + +Delete a user with a given ID. + +`userStorage.delete(userId)` + +Attempts to delete the user with the given user ID from the database. If the user does not exist, a *UserNotFound* exception will be thrown. The method always returns *null*. + +*Parameter* + +* *userId*: a user *_key*. + +@EXAMPLES + +```js +userStorage.delete(userId); +``` + +!SUBSUBSECTION Tell a user to delete itself + +Delete a user from the database. + +`user.delete()` + +Attempts to delete the user from the database. + +Returns *true* if the user was deleted successfully. + +Returns *false* if the user already didn't exist. + +@EXAMPLES + +```js +var user = userStorage.get(userId); +user.delete(); +``` + +!SUBSECTION Save a user + +Save a user to the database. + +`user.save()` + +In order to commit changes made to the user in your code, you need to call this method. + +@EXAMPLES + +```js +var userData = user.get('userData'); +userData.counter++; +user.save(); +``` diff --git a/Documentation/Books/Users/Installing/Linux.mdpp b/Documentation/Books/Users/Installing/Linux.mdpp index a5fd65236b..5094834664 100644 --- a/Documentation/Books/Users/Installing/Linux.mdpp +++ b/Documentation/Books/Users/Installing/Linux.mdpp @@ -32,7 +32,7 @@ provided by @mgiken. !SUBSECTION Debian sid To use ArangoDB on Debian sid (the development version of Debian), a different version -of ICU is required. User basir provided the following instructions for getting ArangoDB 2.0.7 +of ICU is required. User baslr provided the following instructions for getting ArangoDB 2.0.7 to work on an x86_64: [link to Github issue](https://github.com/triAGENS/ArangoDB/issues/865) diff --git a/Documentation/Books/Users/SUMMARY.md b/Documentation/Books/Users/SUMMARY.md index 87376280d2..1823b29570 100644 --- a/Documentation/Books/Users/SUMMARY.md +++ b/Documentation/Books/Users/SUMMARY.md @@ -96,7 +96,12 @@ * [Dependency Injection](Foxx/FoxxInjection.md) * [Foxx Exports](Foxx/FoxxExports.md) * [Foxx Job Queues](Foxx/FoxxQueues.md) + * [Foxx Sessions](Foxx/FoxxSessions.md) * [Optional Functionality](Foxx/FoxxOptional.md) + * [Bundled Applications](FoxxBundledApps/README.md) + * [Session Storage](FoxxBundledApps/Sessions.md) + * [User Storage](FoxxBundledApps/Users.md) + * [Simple Authentication](FoxxBundledApps/SimpleAuth.md) * [Foxx Manager](FoxxManager/README.md) * [First Steps](FoxxManager/FirstSteps.md) @@ -115,6 +120,7 @@ * [Example Setup](Replication/ExampleSetup.md) * [Replication Limitations](Replication/Limitations.md) * [Replication Overhead](Replication/Overhead.md) + * [Replication Events](Replication/Events.md) * [Sharding](Sharding/README.md) * [How to try it out](Sharding/HowTo.md) diff --git a/js/Makefile.files b/js/Makefile.files index e8aa030420..b63c1167a5 100644 --- a/js/Makefile.files +++ b/js/Makefile.files @@ -59,6 +59,9 @@ JAVASCRIPT_JSLINT = \ \ `find @srcdir@/js/apps/system/cerberus -name "*.js"` \ `find @srcdir@/js/apps/system/gharial -name "*.js"` \ + `find @srcdir@/js/apps/system/sessions -name "*.js"` \ + `find @srcdir@/js/apps/system/simple-auth -name "*.js"` \ + `find @srcdir@/js/apps/system/users -name "*.js"` \ \ `find @srcdir@/js/apps/system/aardvark/frontend/js/models -name "*.js"` \ `find @srcdir@/js/apps/system/aardvark/frontend/js/views -name "*.js"` \ diff --git a/js/apps/system/aardvark/frontend/js/templates/foxxActiveView.ejs b/js/apps/system/aardvark/frontend/js/templates/foxxActiveView.ejs index 8668442166..14cb4a6ac9 100644 --- a/js/apps/system/aardvark/frontend/js/templates/foxxActiveView.ejs +++ b/js/apps/system/aardvark/frontend/js/templates/foxxActiveView.ejs @@ -31,5 +31,5 @@ <%}%> -
<%= appInfos[1] %><%= attributes.isSystem ? " (system)" : "" %><%= appInfos[0] === "dev" ? " (dev)" : ""%>
+
<%= appInfos[1] %><%= attributes.isSystem && attributes.mount.charAt(0) === "/" && attributes.mount.charAt(1) === "_" ? " (system)" : "" %><%= appInfos[0] === "dev" ? " (dev)" : ""%>
diff --git a/js/apps/system/aardvark/frontend/js/views/foxxActiveView.js b/js/apps/system/aardvark/frontend/js/views/foxxActiveView.js index b8fdfc4866..6f7b53150a 100644 --- a/js/apps/system/aardvark/frontend/js/views/foxxActiveView.js +++ b/js/apps/system/aardvark/frontend/js/views/foxxActiveView.js @@ -27,14 +27,24 @@ initialize: function(){ this._show = true; - this.buttonConfig = [ - window.modalView.createDeleteButton( - "Uninstall", this.uninstall.bind(this) - ), - window.modalView.createSuccessButton( - "Save", this.changeFoxx.bind(this) - ) - ]; + var mount = this.model.get("mount"); + var isSystem = ( + this.model.get("isSystem") && + mount.charAt(0) === '/' && + mount.charAt(1) === '_' + ); + if (isSystem) { + this.buttonConfig = []; + } else { + this.buttonConfig = [ + window.modalView.createDeleteButton( + "Uninstall", this.uninstall.bind(this) + ), + window.modalView.createSuccessButton( + "Save", this.changeFoxx.bind(this) + ) + ]; + } this.showMod = window.modalView.show.bind( window.modalView, "modalTable.ejs", @@ -72,6 +82,12 @@ isSystem, active, modView = window.modalView; + var mount = this.model.get("mount"); + var editable = !( + this.model.get("isSystem") && + mount.charAt(0) === '/' && + mount.charAt(1) === '_' + ); if (this.model.get("isSystem")) { isSystem = "Yes"; } else { @@ -85,18 +101,24 @@ list.push(modView.createReadOnlyEntry( "id_name", "Name", name )); - list.push(modView.createTextEntry( - "change-mount-point", "Mount", this.model.get("mount"), - "The path where the app can be reached.", - "mount-path", - true, - [ - { - rule: Joi.string().required(), - msg: "No mount-path given." - } - ] - )); + if (editable) { + list.push(modView.createTextEntry( + "change-mount-point", "Mount", this.model.get("mount"), + "The path where the app can be reached.", + "mount-path", + true, + [ + { + rule: Joi.string().required(), + msg: "No mount-path given." + } + ] + )); + } else { + list.push(modView.createReadOnlyEntry( + "change-mount-point", "Mount", this.model.get("mount") + )); + } /* * For the future, update apps to available newer versions * versOptions.push(modView.createOptionEntry(appInfos[2])); @@ -116,9 +138,15 @@ editFoxxDialog: function(event) { event.stopPropagation(); - if (this.model.get("isSystem") || this.model.get("development")) { + var mount = this.model.get("mount"); + var isSystem = ( + this.model.get("isSystem") && + mount.charAt(0) === '/' && + mount.charAt(1) === '_' + ); + if (this.model.get("development")) { this.buttonConfig[0].disabled = true; - } else { + } else if (!isSystem) { delete this.buttonConfig[0].disabled; } this.showMod(this.buttonConfig, this.fillValues()); @@ -163,11 +191,9 @@ }, uninstall: function () { - if (!this.model.get("isSystem")) { - this.model.destroy({ async: false }); - window.modalView.hide(); - this.appsView.reload(); - } + this.model.destroy({ async: false }); + window.modalView.hide(); + this.appsView.reload(); }, showDocu: function(event) { diff --git a/js/apps/system/aardvark/frontend/js/views/foxxInstalledView.js b/js/apps/system/aardvark/frontend/js/views/foxxInstalledView.js index 5991c6eed1..aaf0884927 100644 --- a/js/apps/system/aardvark/frontend/js/views/foxxInstalledView.js +++ b/js/apps/system/aardvark/frontend/js/views/foxxInstalledView.js @@ -91,8 +91,6 @@ "Install", this.installDialog.bind(this) ) ]; - var buttonSystemInfoConfig = [ - ]; this.showMod = window.modalView.show.bind( window.modalView, "modalTable.ejs", @@ -123,12 +121,6 @@ "Application Settings", buttonInfoMultipleVersionsConfigUpdate ); - this.showSystemInfoMod = window.modalView.show.bind( - window.modalView, - "modalTable.ejs", - "Application Settings", - buttonSystemInfoConfig - ); this.showPurgeMod = window.modalView.show.bind( window.modalView, "modalTable.ejs", @@ -334,38 +326,23 @@ infoDialog: function(event) { var name = this.model.get("name"), mountinfo = this.model.collection.gitInfo(name), - versions, isSystem = false, isGit; + isGit; if (mountinfo.git === true) { this.model.set("isGit", mountinfo.git); this.model.set("gitUrl", mountinfo.url); } - if (this.model.get("isSystem")) { - isSystem = true; - } else { - isSystem = false; - } - - versions = this.model.get("versions"); isGit = this.model.get("isGit"); event.stopPropagation(); - if (isSystem === false && !versions && !isGit) { - this.showInfoMod(this.fillInfoValues()); - } - else if (isSystem === false && !versions && isGit) { - this.showInfoModUpdate(this.fillInfoValues()); - } - else if (isSystem === false && versions && !isGit) { - this.showInfoMultipleVersionsMod(this.fillInfoValues()); - } - else if (isSystem === false && versions && isGit) { - this.showInfoMultipleVersionsModUpdate(this.fillInfoValues()); - } - else { - this.showSystemInfoMod(this.fillInfoValues()); - } + + this[ + this.model.get("versions") + ? (isGit ? 'showInfoMultipleVersionsModUpdate' : 'showInfoMultipleVersionsMod') + : (isGit ? 'showInfoModUpdate' : 'showInfoMod') + ](this.fillInfoValues()); + this.selectHighestVersion(); }, @@ -401,9 +378,12 @@ install: function() { var mountPoint = $("#mount-point").val(), version = "", - regex = /^(\/[^\/\s]+)+$/, self = this; - if (!regex.test(mountPoint)){ + if (/^\/_.+$/.test(mountPoint)) { + alert("Sorry, mount paths starting with an underscore are reserved for internal use."); + return false; + } + if (!/^(\/[^\/\s]+)+$/.test(mountPoint)){ alert("Sorry, you have to give a valid mount point, e.g.: /myPath"); return false; } diff --git a/js/apps/system/sessions/app.js b/js/apps/system/sessions/app.js new file mode 100644 index 0000000000..5161f60dbc --- /dev/null +++ b/js/apps/system/sessions/app.js @@ -0,0 +1,68 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, es5: true */ +/*global require, applicationContext */ +(function () { + 'use strict'; + var _ = require('underscore'), + joi = require('joi'), + Foxx = require('org/arangodb/foxx'), + errors = require('./errors'), + controller = new Foxx.Controller(applicationContext), + api = Foxx.requireApp(applicationContext.mount).sessionStorage, + sessionId = joi.string().description('Session ID'); + + controller.post('/', function (req, res) { + var session = api.create(req.body()); + res.status(201); + res.json(session.forClient()); + }) + .errorResponse(SyntaxError, 400, 'Malformed or non-JSON session data.') + .summary('Create session') + .notes('Stores the given sessionData in a new session.'); + + controller.get('/:sid', function (req, res) { + var session = api.get(req.urlParameters.sid); + res.json(session.forClient()); + }) + .pathParam('sid', {type: sessionId}) + .errorResponse(errors.SessionExpired, 404, 'Session has expired') + .errorResponse(errors.SessionNotFound, 404, 'Session does not exist') + .summary('Read session') + .notes('Fetches the session with the given sid.'); + + controller.put('/:sid', function (req, res) { + var body = JSON.parse(req.rawBody()), + session = api.get(req.urlParameters.sid); + session.set('sessionData', body); + session.save(); + res.json(session.forClient()); + }) + .pathParam('sid', {type: sessionId}) + .errorResponse(errors.SessionExpired, 404, 'Session has expired') + .errorResponse(errors.SessionNotFound, 404, 'Session does not exist') + .errorResponse(SyntaxError, 400, 'Malformed or non-JSON session data.') + .summary('Update session (replace)') + .notes('Updates the session with the given sid by replacing the sessionData.'); + + controller.patch('/:sid', function (req, res) { + var body = JSON.parse(req.rawBody()), + session = api.get(req.urlParameters.sid); + _.extend(session.get('sessionData'), body); + session.save(); + res.json(session.forClient()); + }) + .pathParam('sid', {type: sessionId}) + .errorResponse(errors.SessionExpired, 404, 'Session has expired') + .errorResponse(errors.SessionNotFound, 404, 'Session does not exist') + .errorResponse(SyntaxError, 400, 'Malformed or non-JSON session data.') + .summary('Update session') + .notes('Updates the session with the given sid by merging its sessionData.'); + + controller.delete('/:sid', function (req, res) { + api.destroy(req.urlParameters.sid); + res.status(204); + }) + .pathParam('sid', {type: sessionId}) + .errorResponse(errors.SessionNotFound, 404, 'Session does not exist') + .summary('Delete session') + .notes('Removes the session with the given sid from the database.'); +}()); \ No newline at end of file diff --git a/js/apps/system/sessions/errors.js b/js/apps/system/sessions/errors.js new file mode 100644 index 0000000000..b55816c382 --- /dev/null +++ b/js/apps/system/sessions/errors.js @@ -0,0 +1,18 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, es5: true */ +/*global require, exports */ +(function () { + 'use strict'; + + function SessionNotFound(sid) { + this.message = 'Session with session id ' + sid + ' not found.'; + } + SessionNotFound.prototype = new Error(); + + function SessionExpired(sid) { + this.message = 'Session with session id ' + sid + ' has expired.'; + } + SessionExpired.prototype = Object.create(SessionNotFound.prototype); + + exports.SessionNotFound = SessionNotFound; + exports.SessionExpired = SessionExpired; +}()); \ No newline at end of file diff --git a/js/apps/system/sessions/manifest.json b/js/apps/system/sessions/manifest.json new file mode 100644 index 0000000000..ac6ca75d76 --- /dev/null +++ b/js/apps/system/sessions/manifest.json @@ -0,0 +1,44 @@ +{ + "name": "sessions", + "author": "Alan Plum", + "version": "0.1", + "isSystem": true, + "description": "Session storage for Foxx.", + + "controllers": { + "/": "app.js" + }, + + "exports": { + "sessionStorage": "storage.js" + }, + + "defaultDocument": "", + + "lib": ".", + + "setup": "setup.js", + + "configuration": { + "timeToLive": { + "description": "Session expiry timeout in milliseconds.", + "type": "integer", + "default": 604800000 + }, + "ttlType": { + "description": "Timestamp session expiry should be checked against.", + "type": "string", + "default": "created" + }, + "sidTimestamp": { + "description": "Append a timestamp to the session id.", + "type": "boolean", + "default": false + }, + "sidLength": { + "description": "Length of the random part of the session id", + "type": "integer", + "default": 20 + } + } +} diff --git a/js/apps/system/sessions/setup.js b/js/apps/system/sessions/setup.js new file mode 100644 index 0000000000..593d6a228f --- /dev/null +++ b/js/apps/system/sessions/setup.js @@ -0,0 +1,11 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, es5: true */ +/*global require, applicationContext */ +(function () { + 'use strict'; + var db = require('org/arangodb').db, + sessionsName = applicationContext.collectionName('sessions'); + + if (db._collection(sessionsName) === null) { + db._create(sessionsName); + } +}()); \ No newline at end of file diff --git a/js/apps/system/sessions/storage.js b/js/apps/system/sessions/storage.js new file mode 100644 index 0000000000..77794310a6 --- /dev/null +++ b/js/apps/system/sessions/storage.js @@ -0,0 +1,200 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, es5: true */ +/*global require, exports, applicationContext */ +(function () { + 'use strict'; + var _ = require('underscore'), + joi = require('joi'), + internal = require('internal'), + arangodb = require('org/arangodb'), + db = arangodb.db, + addCookie = require('org/arangodb/actions').addCookie, + crypto = require('org/arangodb/crypto'), + Foxx = require('org/arangodb/foxx'), + errors = require('./errors'), + cfg = applicationContext.configuration, + Session = Foxx.Model.extend({ + schema: { + _key: joi.string().required(), + uid: joi.string().optional(), + sessionData: joi.object().required(), + userData: joi.object().required(), + created: joi.number().integer().required(), + lastAccess: joi.number().integer().required(), + lastUpdate: joi.number().integer().required() + } + }), + sessions = new Foxx.Repository( + applicationContext.collection('sessions'), + {model: Session} + ); + + function generateSessionId() { + var sid = ''; + if (cfg.sidTimestamp) { + sid = internal.base64Encode(Number(new Date())); + if (cfg.sidLength === 0) { + return sid; + } + sid += '-'; + } + return sid + internal.genRandomAlphaNumbers(cfg.sidLength || 10); + } + + function createSession(sessionData) { + var sid = generateSessionId(cfg), + now = Number(new Date()), + session = new Session({ + _key: sid, + sid: sid, + sessionData: sessionData || {}, + userData: {}, + created: now, + lastAccess: now, + lastUpdate: now + }); + sessions.save(session); + return session; + } + + function getSession(sid) { + var session; + db._executeTransaction({ + collections: { + read: [sessions.collection.name()], + write: [sessions.collection.name()] + }, + action: function () { + try { + session = sessions.byId(sid); + session.enforceTimeout(); + } catch (err) { + if ( + err instanceof arangodb.ArangoError + && err.errorNum === arangodb.ERROR_ARANGO_DOCUMENT_NOT_FOUND + ) { + throw new errors.SessionNotFound(sid); + } else { + throw err; + } + } + var now = Number(new Date()); + sessions.collection.update(session.forDB(), { + lastAccess: now + }); + session.set('lastAccess', now); + } + }); + return session; + } + + function deleteSession(sid) { + try { + sessions.removeById(sid); + } catch (err) { + if ( + err instanceof arangodb.ArangoError + && err.errorNum === arangodb.ERROR_ARANGO_DOCUMENT_NOT_FOUND + ) { + throw new errors.SessionNotFound(sid); + } else { + throw err; + } + } + return null; + } + + function fromCookie(req, cookieName, secret) { + var session = null, + value = req.cookies[cookieName], + signature; + if (value) { + if (secret) { + signature = req.cookies[cookieName + '_sig'] || ''; + if (!crypto.constantEquals(signature, crypto.hmac(secret, value))) { + return null; + } + } + try { + session = getSession(value); + } catch (e) { + if (!(e instanceof errors.SessionNotFound)) { + throw e; + } + } + } + return session; + } + + _.extend(Session.prototype, { + enforceTimeout: function () { + if (!cfg.timeToLive) { + return; + } + var now = Number(new Date()), + prop = cfg.ttlType; + if (!prop || !this.get(prop)) { + prop = 'created'; + } + if (cfg.timeToLive < (now - this.get(prop))) { + throw new errors.SessionExpired(this.get('_key')); + } + }, + addCookie: function (res, cookieName, secret) { + var value = this.get('_key'), + ttl = cfg.timeToLive; + ttl = ttl ? Math.floor(ttl / 1000) : undefined; + addCookie(res, cookieName, value, ttl); + if (secret) { + addCookie(res, cookieName + '_sig', crypto.hmac(secret, value), ttl); + } + }, + clearCookie: function (res, cookieName, secret) { + addCookie(res, cookieName, '', -(7 * 24 * 60 * 60)); + if (secret) { + addCookie(res, cookieName + '_sig', '', -(7 * 24 * 60 * 60)); + } + }, + setUser: function (user) { + var session = this; + if (user) { + session.set('uid', user.get('_key')); + session.set('userData', user.get('userData')); + } else { + delete session.attributes.uid; + session.set('userData', {}); + } + return session; + }, + save: function () { + var session = this, + now = Number(new Date()); + session.set('lastAccess', now); + session.set('lastUpdate', now); + sessions.replace(session); + return session; + }, + delete: function () { + var session = this, + now = Number(new Date()); + session.set('lastAccess', now); + session.set('lastUpdate', now); + try { + deleteSession(session.get('_key')); + return true; + } catch (e) { + if (e instanceof errors.SessionNotFound) { + return false; + } + throw e; + } + } + }); + + exports.fromCookie = fromCookie; + exports.create = createSession; + exports.get = getSession; + exports.delete = deleteSession; + exports.errors = errors; + exports.repository = sessions; + exports._generateSessionId = generateSessionId; +}()); \ No newline at end of file diff --git a/js/apps/system/simple-auth/auth.js b/js/apps/system/simple-auth/auth.js new file mode 100644 index 0000000000..725a7daef0 --- /dev/null +++ b/js/apps/system/simple-auth/auth.js @@ -0,0 +1,29 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, es5: true */ +/*global require, exports, applicationContext */ +(function () { + 'use strict'; + var crypto = require('org/arangodb/crypto'), + cfg = applicationContext.configuration; + + function verifyPassword(authData, password) { + if (!authData) { + authData = {}; + } + var hashMethod = authData.method || cfg.hashMethod, + salt = authData.salt || '', + storedHash = authData.hash || '', + generatedHash = crypto[hashMethod](salt + password); + // non-lazy comparison to avoid timing attacks + return crypto.constantEquals(storedHash, generatedHash); + } + + function hashPassword(password) { + var hashMethod = cfg.hashMethod, + salt = crypto.genRandomAlphaNumbers(cfg.saltLength), + hash = crypto[hashMethod](salt + password); + return {method: hashMethod, salt: salt, hash: hash}; + } + + exports.verifyPassword = verifyPassword; + exports.hashPassword = hashPassword; +}()); \ No newline at end of file diff --git a/js/apps/system/simple-auth/manifest.json b/js/apps/system/simple-auth/manifest.json new file mode 100644 index 0000000000..1f803ba56a --- /dev/null +++ b/js/apps/system/simple-auth/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "simple-auth", + "author": "Alan Plum", + "version": "0.1", + "isSystem": true, + "description": "Simple password-based authentication for Foxx.", + + "exports": { + "auth": "auth.js" + }, + + "defaultDocument": "", + + "lib": ".", + + "configuration": { + "hashMethod": { + "description": "Cryptographic hash function to use for new passwords.", + "type": "string", + "default": "sha256" + }, + "saltLength": { + "description": "Length of new salts.", + "type": "integer", + "default": 16 + } + } +} diff --git a/js/apps/system/users/errors.js b/js/apps/system/users/errors.js new file mode 100644 index 0000000000..59a268dd22 --- /dev/null +++ b/js/apps/system/users/errors.js @@ -0,0 +1,18 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, es5: true */ +/*global require, exports */ +(function () { + 'use strict'; + + function UserNotFound(uid) { + this.message = 'User with user id ' + uid + ' not found.'; + } + UserNotFound.prototype = new Error(); + + function UsernameNotAvailable(username) { + this.message = 'The username ' + username + ' is not available or already taken.'; + } + UsernameNotAvailable.prototype = new Error(); + + exports.UserNotFound = UserNotFound; + exports.UsernameNotAvailable = UsernameNotAvailable; +}()); \ No newline at end of file diff --git a/js/apps/system/users/manifest.json b/js/apps/system/users/manifest.json new file mode 100644 index 0000000000..b0e437bd65 --- /dev/null +++ b/js/apps/system/users/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "users", + "author": "Alan Plum", + "version": "0.1", + "isSystem": true, + "description": "Username-based user storage for Foxx.", + + "exports": { + "userStorage": "storage.js" + }, + + "defaultDocument": "", + + "lib": ".", + + "setup": "setup.js" +} diff --git a/js/apps/system/users/setup.js b/js/apps/system/users/setup.js new file mode 100644 index 0000000000..564bda8c74 --- /dev/null +++ b/js/apps/system/users/setup.js @@ -0,0 +1,11 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, es5: true */ +/*global require, applicationContext */ +(function () { + 'use strict'; + var db = require('org/arangodb').db, + usersName = applicationContext.collectionName('users'); + + if (db._collection(usersName) === null) { + db._create(usersName); + } +}()); \ No newline at end of file diff --git a/js/apps/system/users/storage.js b/js/apps/system/users/storage.js new file mode 100644 index 0000000000..0a02c83569 --- /dev/null +++ b/js/apps/system/users/storage.js @@ -0,0 +1,122 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, es5: true */ +/*global require, exports, applicationContext */ +(function () { + 'use strict'; + var _ = require('underscore'), + joi = require('joi'), + arangodb = require('org/arangodb'), + db = arangodb.db, + Foxx = require('org/arangodb/foxx'), + errors = require('./errors'), + User = Foxx.Model.extend({ + schema: { + user: joi.string().required(), + authData: joi.object().required(), + userData: joi.object().required() + } + }), + users = new Foxx.Repository( + applicationContext.collection('users'), + {model: User} + ); + + function resolve(username) { + var user = users.firstExample({user: username}); + if (!user.get('_key')) { + return null; + } + return user; + } + + function listUsers() { + return users.collection.all().toArray().map(function (user) { + return user.user; + }).filter(Boolean); + } + + function createUser(username, userData) { + if (!userData) { + userData = {}; + } + if (!username) { + throw new Error('Must provide username!'); + } + var user; + db._executeTransaction({ + collections: { + read: [users.collection.name()], + write: [users.collection.name()] + }, + action: function () { + if (resolve(username)) { + throw new errors.UsernameNotAvailable(username); + } + user = new User({ + user: username, + userData: userData, + authData: {} + }); + users.save(user); + } + }); + return user; + } + + function getUser(uid) { + var user; + try { + user = users.byId(uid); + } catch (err) { + if ( + err instanceof arangodb.ArangoError + && err.errorNum === arangodb.ERROR_ARANGO_DOCUMENT_NOT_FOUND + ) { + throw new errors.UserNotFound(uid); + } + throw err; + } + return user; + } + + function deleteUser(uid) { + try { + users.removeById(uid); + } catch (err) { + if ( + err instanceof arangodb.ArangoError + && err.errorNum === arangodb.ERROR_ARANGO_DOCUMENT_NOT_FOUND + ) { + throw new errors.UserNotFound(uid); + } + throw err; + } + return null; + } + + _.extend(User.prototype, { + save: function () { + var user = this; + users.replace(user); + return user; + }, + delete: function () { + try { + deleteUser(this.get('_key')); + return true; + } catch (e) { + if (e instanceof errors.UserNotFound) { + return false; + } + throw e; + } + } + }); + + exports.resolve = resolve; + exports.list = listUsers; + exports.create = createUser; + exports.get = getUser; + exports.delete = deleteUser; + exports.errors = errors; + exports.repository = users; +}()); \ No newline at end of file diff --git a/js/common/tests/shell-database.js b/js/common/tests/shell-database.js index 22ac7d5ae6..72caa2ccea 100644 --- a/js/common/tests/shell-database.js +++ b/js/common/tests/shell-database.js @@ -251,14 +251,14 @@ function DatabaseSuite () { assertTrue(internal.db._createDatabase("UnitTestsDatabase0", { }, users)); internal.db._useDatabase("UnitTestsDatabase0"); - var m = require("org/arangodb/users"); - var user = m.document("admin"); + var userManager = require("org/arangodb/users"); + var user = userManager.document("admin"); assertEqual("admin", user.user); assertTrue(user.active); assertEqual("m", user.extra.gender); - user = m.document("foo"); + user = userManager.document("foo"); assertEqual("foo", user.user); assertFalse(user.active); assertEqual("f", user.extra.gender); @@ -288,8 +288,8 @@ function DatabaseSuite () { assertTrue(internal.db._createDatabase("UnitTestsDatabase0", { }, users)); internal.db._useDatabase("UnitTestsDatabase0"); - var m = require("org/arangodb/users"); - var user = m.document("admin"); + var userManager = require("org/arangodb/users"); + var user = userManager.document("admin"); assertEqual("admin", user.user); assertTrue(user.active); assertEqual("m", user.extra.gender); diff --git a/js/common/tests/shell-users.js b/js/common/tests/shell-users.js index a060a90a06..a32e7cb914 100644 --- a/js/common/tests/shell-users.js +++ b/js/common/tests/shell-users.js @@ -129,8 +129,8 @@ function UsersSuite () { users.save(username, passwd); assertEqual(username, c.firstExample({ user: username }).user); - - try { + + try { users.save(username, passwd); fail(); } diff --git a/js/server/modules/org/arangodb/foxx/authentication.js b/js/server/modules/org/arangodb/foxx/authentication.js index dd43b87ff9..57563c5626 100644 --- a/js/server/modules/org/arangodb/foxx/authentication.js +++ b/js/server/modules/org/arangodb/foxx/authentication.js @@ -52,7 +52,7 @@ var db = require("org/arangodb").db, CookieAuthentication, Authentication, UserAlreadyExistsError, - UnauthorizedError; + UnauthorizedError = require("./sessions").UnauthorizedError; // ----------------------------------------------------------------------------- // --SECTION-- helper functions @@ -711,7 +711,7 @@ Users.prototype.exists = function (identifier) { }; //////////////////////////////////////////////////////////////////////////////// -/// @brief check whether a user is valid +/// @brief check whether a user is valid //////////////////////////////////////////////////////////////////////////////// Users.prototype.isValid = function (identifier, password) { diff --git a/js/server/modules/org/arangodb/foxx/controller.js b/js/server/modules/org/arangodb/foxx/controller.js index b6ac6f87d0..cf96fb65f0 100644 --- a/js/server/modules/org/arangodb/foxx/controller.js +++ b/js/server/modules/org/arangodb/foxx/controller.js @@ -1,4 +1,4 @@ -/*jslint indent: 2, nomen: true, maxlen: 120, regexp: true */ +/*jslint indent: 2, nomen: true, maxlen: 120, regexp: true, vars: true */ /*global module, require, exports */ //////////////////////////////////////////////////////////////////////////////// @@ -43,8 +43,8 @@ var Controller, // ----------------------------------------------------------------------------- //////////////////////////////////////////////////////////////////////////////// -/// @brief Create a new Controller /// @startDocuBlock JSF_foxx_controller_initializer +/// /// `new FoxxController(applicationContext, options)` /// /// This creates a new Controller. The first argument is the controller @@ -60,6 +60,7 @@ var Controller, /// urlPrefix: "/meadow" /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// @@ -221,6 +222,7 @@ extend(Controller.prototype, { /// // Take this request and deal with it! /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// @@ -244,6 +246,7 @@ extend(Controller.prototype, { /// // Take this request and deal with it! /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// @@ -267,6 +270,7 @@ extend(Controller.prototype, { /// // Take this request and deal with it! /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// @@ -290,6 +294,7 @@ extend(Controller.prototype, { /// // Take this request and deal with it! /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// @@ -321,6 +326,7 @@ extend(Controller.prototype, { /// // Take this request and deal with it! /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// @@ -351,6 +357,7 @@ extend(Controller.prototype, { /// //Do some crazy request logging /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// @@ -388,6 +395,7 @@ extend(Controller.prototype, { /// //Do some crazy response logging /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// @@ -448,13 +456,14 @@ extend(Controller.prototype, { /// @EXAMPLES /// /// ```js -/// app.activateAuthentication({ -/// type: "cookie", -/// cookieLifetime: 360000, -/// cookieName: "my_cookie", -/// sessionLifetime: 400, -/// }); +/// app.activateAuthentication({ +/// type: "cookie", +/// cookieLifetime: 360000, +/// cookieName: "my_cookie", +/// sessionLifetime: 400, +/// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// activateAuthentication: function (opts) { @@ -496,6 +505,7 @@ extend(Controller.prototype, { /// } /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// login: function (route, opts) { @@ -533,6 +543,7 @@ extend(Controller.prototype, { /// } /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// logout: function (route, opts) { @@ -581,6 +592,7 @@ extend(Controller.prototype, { /// } /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// register: function (route, opts) { @@ -622,14 +634,98 @@ extend(Controller.prototype, { /// } /// }); /// ``` +/// /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// changePassword: function (route, opts) { 'use strict'; var authentication = require("org/arangodb/foxx/authentication"); return this.post(route, authentication.createStandardChangePasswordHandler(this.getUsers(), opts)); - } + }, +//////////////////////////////////////////////////////////////////////////////// +/// @fn JSF_foxx_controller_getSessions +/// @brief Get the sessions object of this controller +//////////////////////////////////////////////////////////////////////////////// + getSessions: function () { + 'use strict'; + return this.sessions; + }, + +//////////////////////////////////////////////////////////////////////////////// +/// @startDocuBlock JSF_foxx_controller_activateSessions +/// +/// `FoxxController#activateAuthentication(opts)` +/// +/// To activate sessions for this sessions, first call this function. +/// Provide the following arguments: +/// +/// * *type*: Currently we only support *cookie*, but this will change in the future. Defaults to *"cookie"*. +/// * *cookieName*: A string used as the name of the cookie. Defaults to *"sid"*. +/// * *cookieSecret*: A secret string used to sign the cookie (as "*cookieName*_sig"). Optional. +/// * *autoCreateSession*: Whether to always create a session if none exists. Defaults to *true*. +/// * *sessionStorageApp*: Mount path of the app to use for sessions. Defaults to */_system/sessions* +/// +/// +/// @EXAMPLES +/// +/// ```js +/// app.activateSessions({ +/// type: "cookie", +/// cookieName: "my_cookie", +/// autoCreateSession: true, +/// sessionStorageApp: "/my-sessions" +/// }); +/// ``` +/// +/// @endDocuBlock +//////////////////////////////////////////////////////////////////////////////// + activateSessions: function (opts) { + 'use strict'; + var sessions = require("org/arangodb/foxx/sessions"); + + this.sessions = new sessions.Sessions(opts); + sessions.decorateController(this.sessions, this); + }, + +//////////////////////////////////////////////////////////////////////////////// +/// @startDocuBlock JSF_foxx_controller_destroySession +/// +/// `FoxxController#logout(path, opts)` +/// +/// This adds a path to your app for the destroySession functionality. +/// You can customize it with custom *before* and *after* function: +/// *before* is a function that you can define to do something before +/// the session is destroyed. +/// *after* is a function that you can define to do something after the +/// session is destroyed. This defaults to a function that returns a +/// JSON object with *message* set to "logged out". +/// Both *before* and *after* should take request and result as arguments. +/// If you only want to provide an *after* function, you can pass the +/// function directly instead of an object. +/// +/// @EXAMPLES +/// +/// ```js +/// app.destroySession('/logout', function (req, res) { +/// res.json({"message": "Bye, Bye"}); +/// }); +/// ``` +/// +/// @endDocuBlock +//////////////////////////////////////////////////////////////////////////////// + destroySession: function (route, opts) { + 'use strict'; + var method = opts.method; + if (typeof method === 'string') { + method = method.toLowerCase(); + } + if (!method || typeof this[method] !== 'function') { + method = 'post'; + } + var sessions = require("org/arangodb/foxx/sessions"); + return this[method](route, sessions.createDestroySessionHandler(this.getSessions(), opts)); + } }); exports.Controller = Controller; diff --git a/js/server/modules/org/arangodb/foxx/manager.js b/js/server/modules/org/arangodb/foxx/manager.js index 74ed9e3f7f..ff320c7e3f 100644 --- a/js/server/modules/org/arangodb/foxx/manager.js +++ b/js/server/modules/org/arangodb/foxx/manager.js @@ -192,14 +192,13 @@ function extendContext (context, app, root) { 'use strict'; var cp = context.collectionPrefix; - var cname = ""; - if (cp !== "") { - cname = cp + "_"; + if (cp !== "" && cp !== "_") { + cp += "_"; } context.collectionName = function (name) { - var replaced = (cname + name).replace(/[^a-zA-Z0-9]/g, '_').replace(/(^_+|_+$)/g, '').substr(0, 64); + var replaced = (cp + name.replace(/[^a-zA-Z0-9]/g, '_').replace(/(^_+|_+$)/g, '')).substr(0, 64); if (replaced.length === 0) { throw new Error("Cannot derive collection name from '" + name + "'"); @@ -1023,6 +1022,24 @@ function checkConfiguration (app, options) { return false; } +//////////////////////////////////////////////////////////////////////////////// +/// @brief returns collection prefix for system apps +//////////////////////////////////////////////////////////////////////////////// + + function systemCollectionPrefix (appName) { + 'use strict'; + + if (appName === "sessions") { + return "_"; + } + + if (appName === "users") { + return "_"; + } + + return false; + } + // ----------------------------------------------------------------------------- // --SECTION-- public functions // ----------------------------------------------------------------------------- @@ -1236,7 +1253,7 @@ exports.unmount = function (mount) { var doc = mountFromId(mount); - if (doc.isSystem) { + if (doc.isSystem && mount.charAt(1) === '_') { throw new Error("Cannot unmount system application"); } @@ -1764,7 +1781,12 @@ exports.initializeFoxx = function () { var found = aal.firstExample({ type: "mount", mount: mount }); if (found === null) { - exports.mount(appName, mount, {reload: false}); + var opts = {reload: false}; + var prefix = systemCollectionPrefix(appName); + if (prefix) { + opts.collectionPrefix = prefix; + } + exports.mount(appName, mount, opts); var doc = mountFromId(mount); var app = appFromAppId(doc.app); diff --git a/js/server/modules/org/arangodb/foxx/request_context.js b/js/server/modules/org/arangodb/foxx/request_context.js index 464b792828..4db76f7417 100644 --- a/js/server/modules/org/arangodb/foxx/request_context.js +++ b/js/server/modules/org/arangodb/foxx/request_context.js @@ -35,9 +35,9 @@ var RequestContext, extend = _.extend, internal = require("org/arangodb/foxx/internals"), is = require("org/arangodb/is"), - UnauthorizedError = require("org/arangodb/foxx/authentication").UnauthorizedError, elementExtractFactory, bubbleWrapFactory, + UnauthorizedError = require("org/arangodb/foxx/sessions").UnauthorizedError, createErrorBubbleWrap, createBodyParamBubbleWrap, addCheck; @@ -524,7 +524,8 @@ extend(RequestContext.prototype, { /// /// `FoxxController#onlyIf(code, reason)` /// -/// Please activate authentification for this app if you want to use this function. +/// Please activate sessions for this app if you want to use this function. +/// Or activate authentication (deprecated). /// If the user is logged in, it will do nothing. Otherwise it will respond with /// the status code and the reason you provided (the route handler won't be called). /// This will also add the according documentation for this route. @@ -543,7 +544,10 @@ extend(RequestContext.prototype, { var check; check = function (req) { - if (!(req.user && req.currentSession)) { + if ( + !(req.session && req.session.get('uid')) // new and shiny + && !(req.user && req.currentSession) // old and busted + ) { throw new UnauthorizedError(); } }; diff --git a/js/server/modules/org/arangodb/foxx/sessions.js b/js/server/modules/org/arangodb/foxx/sessions.js new file mode 100644 index 0000000000..37a8d132c3 --- /dev/null +++ b/js/server/modules/org/arangodb/foxx/sessions.js @@ -0,0 +1,237 @@ +/*jslint indent: 2, nomen: true, maxlen: 120, es5: true */ +/*global require, exports */ + +//////////////////////////////////////////////////////////////////////////////// +/// @brief Foxx Sessions +/// +/// @file +/// +/// DISCLAIMER +/// +/// Copyright 2013 triagens GmbH, Cologne, Germany +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// +/// Copyright holder is triAGENS GmbH, Cologne, Germany +/// +/// @author Jan Steemann, Lucas Dohmen +/// @author Copyright 2013, triAGENS GmbH, Cologne, Germany +//////////////////////////////////////////////////////////////////////////////// + +var Foxx = require('org/arangodb/foxx'); + +// ----------------------------------------------------------------------------- +// --SECTION-- helper functions +// ----------------------------------------------------------------------------- + +//////////////////////////////////////////////////////////////////////////////// +/// @addtogroup Foxx +/// @{ +//////////////////////////////////////////////////////////////////////////////// + +//////////////////////////////////////////////////////////////////////////////// +/// @brief decorates the controller with session logic +//////////////////////////////////////////////////////////////////////////////// + +function decorateController(auth, controller) { + 'use strict'; + var cfg = auth.configuration; + + controller.before('/*', function (req) { + var sessions = auth.getSessionStorage(); + if (cfg.type === 'cookie') { + req.session = sessions.fromCookie(req, cfg.cookieName, cfg.cookieSecret); + if (!req.session && cfg.autoCreateSession) { + req.session = sessions.create(); + } + } + }); + + controller.after('/*', function (req, res) { + if (req.session) { + if (cfg.type === 'cookie') { + req.session.addCookie(res, cfg.cookieName, cfg.cookieSecret); + } + } + }); +} + +//////////////////////////////////////////////////////////////////////////////// +/// @brief convenience wrapper for session destruction logic +//////////////////////////////////////////////////////////////////////////////// + +function createDestroySessionHandler(auth, opts) { + 'use strict'; + if (!opts) { + opts = {}; + } + if (typeof opts === 'function') { + opts = {after: opts}; + } + if (!opts.after) { + opts.after = function (req, res) { + res.json({message: 'logged out'}); + }; + } + var cfg = auth.configuration; + return function (req, res, injected) { + if (typeof opts.before === 'function') { + opts.before(req, res, injected); + } + if (req.session) { + req.session.delete(); + } + if (cfg.autoCreateSession) { + req.session = auth.getSessionStorage().create(); + } else { + if (cfg.type === 'cookie') { + req.session.clearCookie(res, cfg.cookieName, cfg.cookieSecret); + } + delete req.session; + } + if (typeof opts.after === 'function') { + opts.after(req, res, injected); + } + }; +} + +//////////////////////////////////////////////////////////////////////////////// +/// @} +//////////////////////////////////////////////////////////////////////////////// + +// ----------------------------------------------------------------------------- +// --SECTION-- FOXX SESSIONS +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +// --SECTION-- constructors and destructors +// ----------------------------------------------------------------------------- + +//////////////////////////////////////////////////////////////////////////////// +/// @addtogroup Foxx +/// @{ +//////////////////////////////////////////////////////////////////////////////// + +//////////////////////////////////////////////////////////////////////////////// +/// @brief constructor +//////////////////////////////////////////////////////////////////////////////// + +function Sessions(opts) { + 'use strict'; + if (!opts) { + opts = {}; + } + if (opts.type !== 'cookie') { + throw new Error('Only "cookie" type sessions are supported at this time.'); + } + if (opts.cookieSecret && typeof opts.cookieSecret !== 'string') { + throw new Error('Cookie secret must be a string or empty.'); + } + if (opts.cookieName && typeof opts.cookieName !== 'string') { + throw new Error('Cookie name must be a string or empty.'); + } + + if (!opts.type) { + opts.type = 'cookie'; + } + if (!opts.cookieName) { + opts.cookieName = 'sid'; + } + if (opts.autoCreateSession !== false) { + opts.autoCreateSession = true; + } + if (!opts.sessionStorageApp) { + opts.sessionStorageApp = '/_system/sessions'; + } + this.configuration = opts; +} + +//////////////////////////////////////////////////////////////////////////////// +/// @} +//////////////////////////////////////////////////////////////////////////////// + +// ----------------------------------------------------------------------------- +// --SECTION-- public functions +// ----------------------------------------------------------------------------- + +//////////////////////////////////////////////////////////////////////////////// +/// @addtogroup Foxx +/// @{ +//////////////////////////////////////////////////////////////////////////////// + +//////////////////////////////////////////////////////////////////////////////// +/// @brief fetches the session storage +//////////////////////////////////////////////////////////////////////////////// + +Sessions.prototype.getSessionStorage = function () { + 'use strict'; + return Foxx.requireApp(this.configuration.sessionStorageApp).sessionStorage; +}; + +//////////////////////////////////////////////////////////////////////////////// +/// @} +//////////////////////////////////////////////////////////////////////////////// + +// ----------------------------------------------------------------------------- +// --SECTION-- custom errors +// ----------------------------------------------------------------------------- + +// http://stackoverflow.com/questions/783818/how-do-i-create-a-custom-error-in-javascript + +//////////////////////////////////////////////////////////////////////////////// +/// @addtogroup Foxx +/// @{ +//////////////////////////////////////////////////////////////////////////////// + +//////////////////////////////////////////////////////////////////////////////// +/// @brief constructor +//////////////////////////////////////////////////////////////////////////////// + +function UnauthorizedError(message) { + 'use strict'; + this.message = message; + this.statusCode = 401; +} + +UnauthorizedError.prototype = new Error(); + +//////////////////////////////////////////////////////////////////////////////// +/// @} +//////////////////////////////////////////////////////////////////////////////// + +// ----------------------------------------------------------------------------- +// --SECTION-- module exports +// ----------------------------------------------------------------------------- + +//////////////////////////////////////////////////////////////////////////////// +/// @addtogroup Foxx +/// @{ +//////////////////////////////////////////////////////////////////////////////// + +exports.UnauthorizedError = UnauthorizedError; +exports.Sessions = Sessions; +exports.decorateController = decorateController; +exports.createDestroySessionHandler = createDestroySessionHandler; + +//////////////////////////////////////////////////////////////////////////////// +/// @} +//////////////////////////////////////////////////////////////////////////////// + +// ----------------------------------------------------------------------------- +// --SECTION-- END-OF-FILE +// ----------------------------------------------------------------------------- + +/// Local Variables: +/// mode: outline-minor +/// outline-regexp: "/// @brief\\|/// @addtogroup\\|/// @page\\|// --SECTION--\\|/// @\\}\\|/\\*jslint" +/// End: diff --git a/js/server/modules/org/arangodb/users.js b/js/server/modules/org/arangodb/users.js index 57dbb6e56a..2e891b7803 100644 --- a/js/server/modules/org/arangodb/users.js +++ b/js/server/modules/org/arangodb/users.js @@ -1,4 +1,4 @@ -/*jslint indent: 2, nomen: true, maxlen: 120, sloppy: true, vars: true, white: true, plusplus: true */ +/*jslint indent: 2, nomen: true, maxlen: 120, sloppy: true, vars: true, white: true, plusplus: true, es5: true */ /*global require, exports, ArangoAgency */ //////////////////////////////////////////////////////////////////////////////// @@ -43,28 +43,31 @@ var ArangoError = arangodb.ArangoError; // --SECTION-- private functions // ----------------------------------------------------------------------------- +//////////////////////////////////////////////////////////////////////////////// +/// @brief converts a user document to the legacy format +//////////////////////////////////////////////////////////////////////////////// + +var convertToLegacyFormat = function (doc) { + return { + user: doc.user, + active: doc.authData.active, + extra: doc.userData || {}, + changePassword: doc.authData.changePassword + }; +}; + //////////////////////////////////////////////////////////////////////////////// /// @brief encode password using SHA256 //////////////////////////////////////////////////////////////////////////////// -var encodePassword = function (password) { - var salt; - var encoded; +var hashPassword = function (password) { + var salt = internal.genRandomAlphaNumbers(16); - var random = crypto.rand(); - if (random === undefined) { - random = "time:" + internal.time(); - } - else { - random = "random:" + random; - } - - salt = crypto.sha256(random); - salt = salt.substr(0,8); - - encoded = "$1$" + salt + "$" + crypto.sha256(salt + password); - - return encoded; + return { + hash: crypto.sha256(salt + password), + salt: salt, + method: "sha256" + }; }; //////////////////////////////////////////////////////////////////////////////// @@ -72,7 +75,7 @@ var encodePassword = function (password) { //////////////////////////////////////////////////////////////////////////////// var validateName = function (username) { - if (typeof username !== 'string' || username === '') { + if (typeof username !== "string" || username === "") { var err = new ArangoError(); err.errorNum = arangodb.errors.ERROR_USER_INVALID_NAME.code; err.errorMessage = arangodb.errors.ERROR_USER_INVALID_NAME.message; @@ -85,8 +88,8 @@ var validateName = function (username) { /// @brief validates password //////////////////////////////////////////////////////////////////////////////// -var validatePassword = function (passwd) { - if (typeof passwd !== 'string') { +var validatePassword = function (password) { + if (typeof password !== "string") { var err = new ArangoError(); err.errorNum = arangodb.errors.ERROR_USER_INVALID_PASSWORD.code; err.errorMessage = arangodb.errors.ERROR_USER_INVALID_PASSWORD.message; @@ -106,7 +109,6 @@ var getStorage = function () { var err = new ArangoError(); err.errorNum = arangodb.errors.ERROR_ARANGO_COLLECTION_NOT_FOUND.code; err.errorMessage = "collection _users not found"; - throw err; } @@ -121,14 +123,14 @@ var getStorage = function () { /// @brief creates a new user //////////////////////////////////////////////////////////////////////////////// -exports.save = function (user, passwd, active, extra, changePassword) { - if (passwd === null || passwd === undefined) { - passwd = ""; +exports.save = function (username, password, active, userData, changePassword) { + if (password === null || password === undefined) { + password = ""; } // validate input - validateName(user); - validatePassword(passwd); + validateName(username); + validatePassword(password); if (active === undefined || active === null) { active = true; // this is the default value @@ -143,47 +145,45 @@ exports.save = function (user, passwd, active, extra, changePassword) { } var users = getStorage(); - var previous = users.firstExample({ user: user }); + var user = users.firstExample({user: username}); - if (previous === null) { - var hash = encodePassword(passwd); - var data = { - user: user, - password: hash, - active: active, - changePassword: changePassword - }; - - if (extra !== undefined) { - data.extra = extra; - } - - var doc = users.save(data); - - // not exports.reload() as this is an abstract method... - require("org/arangodb/users").reload(); - return users.document(doc._id); + if (user !== null) { + var err = new ArangoError(); + err.errorNum = arangodb.errors.ERROR_USER_DUPLICATE.code; + err.errorMessage = arangodb.errors.ERROR_USER_DUPLICATE.message; + throw err; } - var err = new ArangoError(); - err.errorNum = arangodb.errors.ERROR_USER_DUPLICATE.code; - err.errorMessage = arangodb.errors.ERROR_USER_DUPLICATE.message; + var data = { + user: username, + userData: userData || {}, + authData: { + simple: hashPassword(password), + active: Boolean(active), + changePassword: Boolean(changePassword) + } + }; - throw err; + var doc = users.save(data); + + // not exports.reload() as this is an abstract method... + require("org/arangodb/users").reload(); + + return convertToLegacyFormat(users.document(doc._id)); }; //////////////////////////////////////////////////////////////////////////////// /// @brief replaces an existing user //////////////////////////////////////////////////////////////////////////////// -exports.replace = function (user, passwd, active, extra, changePassword) { - if (passwd === null || passwd === undefined) { - passwd = ""; +exports.replace = function (username, password, active, userData, changePassword) { + if (password === null || password === undefined) { + password = ""; } // validate input - validateName(user); - validatePassword(passwd); + validateName(username); + validatePassword(password); if (active === undefined || active === null) { active = true; // this is the default @@ -194,159 +194,146 @@ exports.replace = function (user, passwd, active, extra, changePassword) { } var users = getStorage(); - var previous = users.firstExample({ user: user }); + var user = users.firstExample({user: username}); - if (previous === null) { + if (user === null) { var err = new ArangoError(); err.errorNum = arangodb.errors.ERROR_USER_NOT_FOUND.code; err.errorMessage = arangodb.errors.ERROR_USER_NOT_FOUND.message; - throw err; } - var hash = encodePassword(passwd); var data = { - user: user, - password: hash, - active: active, - changePassword: changePassword + user: username, + userData: userData || {}, + authData: { + simple: hashPassword(password), + active: Boolean(active), + changePassword: Boolean(changePassword) + } }; - if (extra !== undefined) { - data.extra = extra; - } - - users.replace(previous, data); + var doc = users.replace(user, data); // not exports.reload() as this is an abstract method... require("org/arangodb/users").reload(); - return users.document(previous._id); + return convertToLegacyFormat(users.document(doc._id)); }; //////////////////////////////////////////////////////////////////////////////// /// @brief updates an existing user //////////////////////////////////////////////////////////////////////////////// -exports.update = function (user, passwd, active, extra, changePassword) { - +exports.update = function (username, password, active, userData, changePassword) { // validate input - validateName(user); + validateName(username); - if (passwd !== undefined) { - validatePassword(passwd); + if (password !== undefined) { + validatePassword(password); } var users = getStorage(); - var previous = users.firstExample({ user: user }); + var user = users.firstExample({user: username}); - if (previous === null) { + if (user === null) { var err = new ArangoError(); err.errorNum = arangodb.errors.ERROR_USER_NOT_FOUND.code; err.errorMessage = arangodb.errors.ERROR_USER_NOT_FOUND.message; - throw err; } - var data = previous._shallowCopy; + var data = user._shallowCopy; - if (passwd !== undefined) { - var hash = encodePassword(passwd); - data.password = hash; + if (password !== undefined) { + data.authData.simple = hashPassword(password); } if (active !== undefined && active !== null) { - data.active = active; + data.authData.active = active; } - if (extra !== undefined) { - data.extra = extra; + if (userData !== undefined) { + data.userData = userData; } if (changePassword !== undefined && changePassword !== null) { - data.changePassword = changePassword; + data.authData.changePassword = changePassword; } - users.update(previous, data); + users.update(user, data); // not exports.reload() as this is an abstract method... require("org/arangodb/users").reload(); - return users.document(previous._id); + return convertToLegacyFormat(users.document(user._id)); }; //////////////////////////////////////////////////////////////////////////////// /// @brief deletes an existing user //////////////////////////////////////////////////////////////////////////////// -exports.remove = function (user) { +exports.remove = function (username) { // validate input - validateName(user); + validateName(username); var users = getStorage(); - var previous = users.firstExample({ user: user }); + var user = users.firstExample({user: username}); - if (previous === null) { + if (user === null) { var err = new ArangoError(); err.errorNum = arangodb.errors.ERROR_USER_NOT_FOUND.code; err.errorMessage = arangodb.errors.ERROR_USER_NOT_FOUND.message; - throw err; } - users.remove(previous); - // not exports.reload() as this is an abstract method... require("org/arangodb/users").reload(); + + users.remove(user); }; //////////////////////////////////////////////////////////////////////////////// /// @brief gets an existing user //////////////////////////////////////////////////////////////////////////////// -exports.document = function (user) { - +exports.document = function (username) { // validate name - validateName(user); + validateName(username); var users = getStorage(); - var doc = users.firstExample({ user: user }); + var user = users.firstExample({user: username}); - if (doc === null) { + if (user === null) { var err = new ArangoError(); err.errorNum = arangodb.errors.ERROR_USER_NOT_FOUND.code; err.errorMessage = arangodb.errors.ERROR_USER_NOT_FOUND.message; - throw err; } - return { - user: doc.user, - active: doc.active, - extra: doc.extra || {}, - changePassword: doc.changePassword - }; + return convertToLegacyFormat(user); }; //////////////////////////////////////////////////////////////////////////////// /// @brief checks whether a combination of username / password is valid. //////////////////////////////////////////////////////////////////////////////// -exports.isValid = function (user, password) { - var users = getStorage(); - var previous = users.firstExample({ user: user }); +exports.isValid = function (username, password) { + // validate name + validateName(username); - if (previous === null || ! previous.active) { + var users = getStorage(); + var user = users.firstExample({user: username}); + + if (user === null || !user.authData.active) { return false; } - var salted = previous.password.substr(3, 8) + password; - var hex = crypto.sha256(salted); - // penalize the call internal.sleep(Math.random()); - return (previous.password.substr(12) === hex); + var hash = crypto[user.authData.simple.method](user.authData.simple.salt + password); + return crypto.constantEquals(user.authData.simple.hash, hash); }; //////////////////////////////////////////////////////////////////////////////// @@ -354,22 +341,9 @@ exports.isValid = function (user, password) { //////////////////////////////////////////////////////////////////////////////// exports.all = function () { - var cursor = getStorage().all(); - var result = [ ]; + var users = getStorage(); - while (cursor.hasNext()) { - var doc = cursor.next(); - var user = { - user: doc.user, - active: doc.active, - extra: doc.extra || { }, - changePassword: doc.changePassword - }; - - result.push(user); - } - - return result; + return users.all().toArray().map(convertToLegacyFormat); }; //////////////////////////////////////////////////////////////////////////////// @@ -409,11 +383,10 @@ exports.reload = function () { /// @brief sets a password-change token //////////////////////////////////////////////////////////////////////////////// -exports.setPasswordToken = function (user, token) { +exports.setPasswordToken = function (username, token) { var users = getStorage(); - var current = users.firstExample({ user: user }); - - if (current === null) { + var user = users.firstExample({user: username}); + if (user === null) { return null; } @@ -421,7 +394,7 @@ exports.setPasswordToken = function (user, token) { token = internal.genRandomAlphaNumbers(50); } - users.update(current, { passwordToken: token }); + users.update(user, {authData: {passwordToken: token}}); return token; }; @@ -432,13 +405,7 @@ exports.setPasswordToken = function (user, token) { exports.userByToken = function (token) { var users = getStorage(); - var current = users.firstExample({ passwordToken: token }); - - if (current === null) { - return null; - } - - return current.user; + return users.firstExample({"authData.passwordToken": token}); }; //////////////////////////////////////////////////////////////////////////////// @@ -447,18 +414,24 @@ exports.userByToken = function (token) { exports.changePassword = function (token, password) { var users = getStorage(); - var current = users.firstExample({ passwordToken: token }); + var user = users.firstExample({'authData.passwordToken': token}); - if (current === null) { + if (user === null) { return false; } validatePassword(password); - var hash = encodePassword(password); + var authData = user._shallowCopy.authData; - users.update(current, { passwordToken: null, password: hash, changePassword: false }); - exports.reload(); + delete authData.passwordToken; + authData.simple = hashPassword(password); + authData.changePassword = false; + + users.update(user, {authData: authData}); + + // not exports.reload() as this is an abstract method... + require("org/arangodb/users").reload(); return true; }; diff --git a/js/server/tests/shell-foxx.js b/js/server/tests/shell-foxx.js index f80b658b26..a5365a3652 100644 --- a/js/server/tests/shell-foxx.js +++ b/js/server/tests/shell-foxx.js @@ -324,6 +324,36 @@ function SetRoutesFoxxControllerSpec () { assertEqual(error, new Error("Setup authentication first")); }, + + testAddADestroySessionRoute: function () { + var myFunc = function () {}, + routes = app.routingInfo.routes; + + app.activateSessions({ + sessionStorageApp: 'sessions', + cookieName: 'sid', + cookieSecret: 'secret', + type: 'cookie' + }); + app.destroySession('/simple/route', myFunc); + assertEqual(routes[0].docs.httpMethod, 'POST'); + assertEqual(routes[0].url.methods, ["post"]); + }, + + testRefuseDestroySessionWhenSessionsAreNotSetUp: function () { + var myFunc = function () {}, + error; + + try { + app.destroySession('/simple/route', myFunc); + } catch(e) { + error = e; + } + + assertEqual(error, new Error("Setup sessions first")); + } + + }; } @@ -1370,6 +1400,50 @@ function SetupAuthorization () { }; } +function SetupSessions () { + var app; + + return { + testWorksWithAllParameters: function () { + var err; + + app = new FoxxController(fakeContext); + + try { + app.activateSessions({ + sessionStorageApp: 'sessions', + cookieName: 'sid', + cookieSecret: 'secret', + type: 'cookie' + }); + } catch (e) { + err = e; + } + + assertUndefined(err); + }, + + testRefusesUnknownSessionsTypes: function () { + var err; + + app = new FoxxController(fakeContext); + + try { + app.activateSessions({ + sessionStorageApp: 'sessions', + cookieName: 'sid', + cookieSecret: 'secret', + type: 'magic' + }); + } catch (e) { + err = e; + } + + assertEqual(err.message, 'Only "cookie" type sessions are supported at this time.'); + } + }; +} + function FoxxControllerWithRootElement () { var app; @@ -1444,6 +1518,7 @@ jsunity.run(DocumentationAndConstraintsSpec); jsunity.run(AddMiddlewareFoxxControllerSpec); jsunity.run(CommentDrivenDocumentationSpec); jsunity.run(SetupAuthorization); +jsunity.run(SetupSessions); jsunity.run(FoxxControllerWithRootElement); return jsunity.done(); diff --git a/js/server/upgrade-database.js b/js/server/upgrade-database.js index 62c108087e..53502bf315 100644 --- a/js/server/upgrade-database.js +++ b/js/server/upgrade-database.js @@ -726,6 +726,89 @@ } }); +//////////////////////////////////////////////////////////////////////////////// +/// @brief upgradeUserModel +//////////////////////////////////////////////////////////////////////////////// + + // create a unique index on "user" attribute in _users + addTask({ + name: "updateUserModel", + description: "convert documents in _users collection to new format", + + mode: [ MODE_PRODUCTION, MODE_DEVELOPMENT ], + cluster: [ CLUSTER_NONE, CLUSTER_COORDINATOR_GLOBAL ], + database: [ DATABASE_INIT, DATABASE_UPGRADE ], + + task: function () { + var users = getCollection("_users"); + if (! users) { + return false; + } + + var results = users.all().toArray().map(function (oldDoc) { + if (!oldDoc.hasOwnProperty('userData')) { + if (typeof oldDoc.user !== 'string') { + logger.error("user with _key " + oldDoc._key + " has no username"); + return false; + } + if (typeof oldDoc.password !== 'string') { + logger.error("user with username " + oldDoc.user + " has no password"); + return false; + } + var newDoc = { + user: oldDoc.user, + userData: oldDoc.extra || {}, + authData: { + active: Boolean(oldDoc.active), + changePassword: Boolean(oldDoc.changePassword) + } + }; + if (oldDoc.passwordToken) { + newDoc.authData.passwordToken = oldDoc.passwordToken; + } + var passwd = oldDoc.password.split('$'); + if (passwd[0] !== '' || passwd.length !== 4) { + logger.error("user with username " + oldDoc.user + " has unexpected password format"); + return false; + } + newDoc.authData.simple = { + method: 'sha256', + salt: passwd[2], + hash: passwd[3] + }; + var result = users.replace(oldDoc, newDoc); + return !result.errors; + } + if (!oldDoc.hasOwnProperty('authData')) { + logger.error("user with _key " + oldDoc._key + " has no authData"); + return false; + } + return true; + }); + + return results.every(Boolean); + } + }); + +//////////////////////////////////////////////////////////////////////////////// +/// @brief setupSessions +/// +/// set up the collection _sessions +//////////////////////////////////////////////////////////////////////////////// + + addTask({ + name: "setupSessions", + description: "setup _sessions collection", + + mode: [ MODE_PRODUCTION, MODE_DEVELOPMENT ], + cluster: [ CLUSTER_NONE, CLUSTER_COORDINATOR_GLOBAL ], + database: [ DATABASE_INIT, DATABASE_UPGRADE ], + + task: function () { + return createSystemCollection("_sessions", { waitForSync : true }); + } + }); + //////////////////////////////////////////////////////////////////////////////// /// @brief setupGraphs ///