1
0
Fork 0

Merge pull request #920 from triAGENS/foxx-app-sessions

Replace Foxx authentication with Foxx app-based (modular) authentication.
This commit is contained in:
Alan Plum 2014-08-20 15:19:01 +02:00
commit c62715e460
31 changed files with 1866 additions and 234 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,182 @@
!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({username: 'alreadyTaken'});
} catch(err) {
assertTrue(err instanceof userStorage.errors.UsernameNotAvailable);
}
```
!SUBSECTION The user object
User objects are instances of a Foxx model with the following attributes:
* *userData*: application-specific user data. This is an arbitrary object that must at least have a *username* property set to the user's username.
* *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(userData)`
Throws *UsernameNotAvailable* if a user with the given username already exists.
*Parameter*
* *userData*: an arbitrary object that will be stored as the user's *userData* attribute when the model is saved to the database. This object must at least have a *username* property set to a string.
@EXAMPLES
```js
var user = userStorage.create({username: 'malaclypse'});
assertEqual(user.get('userData').username, 'malaclypse');
```
!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.get('userData').username, '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

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