1
0
Fork 0

Merge branch 'devel' of github.com:triAGENS/ArangoDB into devel

This commit is contained in:
Heiko Kernbach 2014-08-20 15:36:04 +02:00
commit 06e0859ef5
32 changed files with 1869 additions and 235 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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);
});
```

View File

@ -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.

View File

@ -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();
```

View File

@ -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)

View File

@ -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)
<!-- 16 -->
* [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)
<!-- 19 -->
* [Sharding](Sharding/README.md)
* [How to try it out](Sharding/HowTo.md)

View File

@ -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"` \

View File

@ -31,5 +31,5 @@
</span>
</div>
<%}%>
<h5 class="collectionName"><%= appInfos[1] %><%= attributes.isSystem ? " (system)" : "" %><%= appInfos[0] === "dev" ? " (dev)" : ""%></h5>
<h5 class="collectionName"><%= appInfos[1] %><%= attributes.isSystem && attributes.mount.charAt(0) === "/" && attributes.mount.charAt(1) === "_" ? " (system)" : "" %><%= appInfos[0] === "dev" ? " (dev)" : ""%></h5>
</script>

View File

@ -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) {

View File

@ -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;
}

View File

@ -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.');
}());

View File

@ -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;
}());

View File

@ -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
}
}
}

View File

@ -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);
}
}());

View File

@ -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;
}());

View File

@ -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;
}());

View File

@ -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
}
}
}

View File

@ -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;
}());

View File

@ -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"
}

View File

@ -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);
}
}());

View File

@ -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;
}());

View File

@ -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);

View File

@ -129,8 +129,8 @@ function UsersSuite () {
users.save(username, passwd);
assertEqual(username, c.firstExample({ user: username }).user);
try {
try {
users.save(username, passwd);
fail();
}

View File

@ -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) {

View File

@ -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;

View File

@ -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);

View File

@ -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();
}
};

View File

@ -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:

View File

@ -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;
};

View File

@ -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();

View File

@ -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
///