510 lines
17 KiB
Markdown
510 lines
17 KiB
Markdown
|
Extended Session Management
|
||
|
===========================
|
||
|
|
||
|
<!-- toc -->
|
||
|
|
||
|
- [Abstract](#abstract)
|
||
|
- [Session Lifecycle](#session-lifecycle)
|
||
|
- [Security](#security)
|
||
|
- [Data Privacy](#data-privacy)
|
||
|
- [API Reference](#api-reference)
|
||
|
- [Example](#example)
|
||
|
- [Performance](#performance)
|
||
|
- [Custom Storage Driver](#custom-storage-driver)
|
||
|
- [Credentials Structure](#credentials-structure)
|
||
|
|
||
|
<!-- tocstop -->
|
||
|
|
||
|
## Abstract
|
||
|
|
||
|
The application router uses a memory store as a session repository to provide
|
||
|
the best runtime performance. However, it is not persisted and it is not shared
|
||
|
across multiple instances of the application router.
|
||
|
|
||
|
*__Note:__ The Limitations above do not prevent the application router from
|
||
|
being scaled out, since session stickiness is in place by default.*
|
||
|
|
||
|
While it is good enough for most of the cases, it may be required to
|
||
|
provide a highly-available solution, which may be achieved by storing
|
||
|
the state (session) of the application router outside - in durable shared
|
||
|
storage.
|
||
|
To allow implementing these qualities, the application router exposes the
|
||
|
*extended session management* API described below.
|
||
|
|
||
|
## Session Lifecycle
|
||
|
|
||
|
The application router stores user agent sessions as JavaScript objects
|
||
|
serialized to strings. It also stores the session timeout associated with
|
||
|
each session, which indicates the amount of time left until session
|
||
|
invalidation.
|
||
|
|
||
|
### Initial Data
|
||
|
|
||
|
During the start of the application router, the internal session store is initiated.
|
||
|
It contains an empty list of sessions and their timeouts. The internal session
|
||
|
store is not available right after the application router instance is
|
||
|
created, but is available in the callback of `approuter.start` and all
|
||
|
the time afterwards until the application router is stopped.
|
||
|
|
||
|
In case an external session storage is used, the application router extension
|
||
|
should perform the following actions to synchronize the internal session
|
||
|
store with the external one:
|
||
|
|
||
|
- Load existing sessions from external storage
|
||
|
- Start the application router
|
||
|
- Populate the application router's internal session store
|
||
|
|
||
|
### Read
|
||
|
|
||
|
A session identifier may be obtained from the request object `req.sessionID`.
|
||
|
|
||
|
On each request, the application router executes registered middlewares
|
||
|
in a certain order and the session is not available to all of them.
|
||
|
|
||
|
- First it passes the request to `approuter.first` middleware.
|
||
|
At this point, there is no session associated with
|
||
|
the incoming request.
|
||
|
- Afterwards, the application router checks if the user is authenticated, reads
|
||
|
the relevant session from the internal session store and puts it into the request
|
||
|
context.
|
||
|
- Next, the application router passes a request to
|
||
|
`approuter.broforeRequestHandler`. At this point, the session object is
|
||
|
available and associated with the incoming request.
|
||
|
- `approuter.beforeErrorHandler` also has access to session.
|
||
|
|
||
|
### Login
|
||
|
|
||
|
When a user agent requests a resource, served via a route that requires
|
||
|
authentication, the application router will request the user agent to
|
||
|
pass authentication first (usually via redirect to XSUAA). At this point,
|
||
|
the application router does not create any session. Only after
|
||
|
the authentication process is finished, the application router creates a session,
|
||
|
stores it in the internal session storage and emits a `login` event.
|
||
|
|
||
|
### Update Session
|
||
|
|
||
|
Any changes made to the session are not stored in the internal session store
|
||
|
immediately, but are accumulated to make a bulk update after the end of the response.
|
||
|
While the request is passed through the chain of middlewares, the session object
|
||
|
may be modified. Also, when the backend access token is close to expire,
|
||
|
the application router may trigger the refresh backend token flow. In both cases,
|
||
|
the actual update of the internal session store is done later on, outside of
|
||
|
the request context.
|
||
|
|
||
|
### Timeout
|
||
|
|
||
|
There is a time-based job in the application router that basis outside
|
||
|
the request context and destroys sessions with an elapsed timeout.
|
||
|
|
||
|
Each time the application router reads a session from the session store,
|
||
|
the timeout of this session is reset to the initial value that may be retrieved
|
||
|
using the [`getDefaultSessionTimeout()`](#sessionstoregetdefaultsessiontimeout)
|
||
|
API.
|
||
|
|
||
|
### Logout
|
||
|
|
||
|
When a user agent requests a URL defined as the `logoutEndpoint` in the
|
||
|
`xs-app.json` file, a central logout process takes place. As part of this
|
||
|
process, the application router emits a `logout` event. More detailed
|
||
|
information about the central logout may be found in
|
||
|
[README.md](../README.md)
|
||
|
|
||
|
## Security
|
||
|
|
||
|
The application router uses session secret to sign session cookies and
|
||
|
prevent tampering. The session secret, by default, is generated using
|
||
|
a random sequence of bytes at the startup of the application router. It is
|
||
|
different for each instance and changed on each restart of the same
|
||
|
instance.
|
||
|
|
||
|
Using the default session secret generation mechanism for highly available
|
||
|
application routers may cause issues in the following scenarios:
|
||
|
|
||
|
- The user agent is authenticated and the session is stored in a session store.
|
||
|
The application router is restarted (due to internal error or triggered
|
||
|
by platform) and a new session secret is generated. The authenticated user
|
||
|
agent makes a request, which contains the session cookies. However, the cookies are
|
||
|
signed using another secret and the application router ignores them.
|
||
|
- The user agent is authenticated and the session is stored in the session store.
|
||
|
The application router instance is unavailable. The authenticated user agent
|
||
|
makes a request to the application router and the request contains the session
|
||
|
cookies. The load balancer forwards the request to another instance of
|
||
|
the application router. However, cookies are signed using another secret and
|
||
|
the application router ignores them.
|
||
|
|
||
|
In both scenarios, the session in the store is no longer accessible, the cookies
|
||
|
sent by the user agent are redundant, and the user agent will be requested to
|
||
|
pass authentication once again.
|
||
|
|
||
|
To avoid the issues described above, the extension that implements the extended session
|
||
|
management mechanism, should make sure to implement the `getSessionSecret` hook.
|
||
|
|
||
|
```js
|
||
|
var ar = AppRouter();
|
||
|
|
||
|
ar.start({
|
||
|
getSessionSecret: function () {
|
||
|
return 'CUSTOM_PERSISTED_SESSION_SECRET';
|
||
|
},
|
||
|
...
|
||
|
});
|
||
|
```
|
||
|
|
||
|
It is recommended to have at least 128 characters in the string that replaces
|
||
|
`CUSTOM_PERSISTED_SESSION_SECRET`.
|
||
|
|
||
|
## Data Privacy
|
||
|
|
||
|
The user agent session potentially contains personal data. By implementing
|
||
|
the custom session management behaviour, you take the responsibility to be
|
||
|
compliant with all personal data protection laws and regulations
|
||
|
(e.g. [GDPR](https://en.wikipedia.org/wiki/General_Data_Protection_Regulation))
|
||
|
that may be applied in the regions, where the application will be used.
|
||
|
|
||
|
## API Reference
|
||
|
|
||
|
### Methods
|
||
|
|
||
|
#### approuter.start(options)
|
||
|
|
||
|
* `options`
|
||
|
* `getSessionSecret` - returns the session secret to be used
|
||
|
by the application router for the signing of the session cookies.
|
||
|
|
||
|
#### approuter.getSessionStore()
|
||
|
|
||
|
returns `SessionStore` instance.
|
||
|
|
||
|
#### sessionStore.getDefaultSessionTimeout()
|
||
|
|
||
|
returns the default session timeout in minutes.
|
||
|
|
||
|
#### sessionStore.getSessionTimeout(sessionId, callback)
|
||
|
|
||
|
* `sessionId` - an unsigned session identifier
|
||
|
* `callback` - `function(error, session)` a function that is called
|
||
|
when the session object is retrieved from the internal session
|
||
|
storage of the application router.
|
||
|
* `error` - an error object in case of an error, otherwise `null`
|
||
|
* `timeout` - time, in minutes, until the session times out
|
||
|
|
||
|
#### sessionStore.get(sessionId, callback)
|
||
|
|
||
|
* `sessionId` - an unsigned session identifier
|
||
|
* `callback` - `function(error, session)` a function that is called
|
||
|
when the session object is retrieved from the internal session
|
||
|
storage of the application router.
|
||
|
* `error` - an error object in case of an error, otherwise `null`
|
||
|
* `session` - the session object
|
||
|
* `id` - session identifier, immutable
|
||
|
|
||
|
#### sessionStore.set(sessionId, sessionString, timeout, callback)
|
||
|
|
||
|
* `sessionId` - an unsigned session identifier
|
||
|
* `sessionString` - a session object serialized to string
|
||
|
* `timeout` - a timestamp in milliseconds, after which the session should be
|
||
|
automatically invalidated
|
||
|
* `callback` - a function that is called after the session is saved in the
|
||
|
internal session storage of the application router
|
||
|
|
||
|
#### sessionStore.update(sessionId, callback, resetTimeout)
|
||
|
|
||
|
* `sessionId` - an unsigned session identifier
|
||
|
* `callback` - `function(currentSession)` function, which returns
|
||
|
session object. Callback function may modify and return current
|
||
|
session object or create and return brand new session object
|
||
|
* `currentSession` - current session object
|
||
|
* `resetTimeout` - a boolean that indicates whether to reset the session timeout
|
||
|
|
||
|
|
||
|
#### sessionStore.destroy(sessionId, callback)
|
||
|
|
||
|
* `sessionId` - an unsigned session identifier
|
||
|
* `callback` - a function that is called after the session is destroyed in
|
||
|
the internal session storage of the application router
|
||
|
|
||
|
### Events
|
||
|
|
||
|
Extension may subscribe to application router events using the standard
|
||
|
[`EventEmitter`](https://nodejs.org/api/events.html) API.
|
||
|
|
||
|
```js
|
||
|
var ar = AppRouter();
|
||
|
|
||
|
ar.on('someEvent', function handler() {
|
||
|
// Handle event
|
||
|
});
|
||
|
```
|
||
|
|
||
|
#### `login`
|
||
|
|
||
|
Emitted when user agent is authenticated.
|
||
|
|
||
|
Parameters:
|
||
|
* `session` - session object
|
||
|
* `id` - session identifier, immutable
|
||
|
|
||
|
#### `logout`
|
||
|
|
||
|
Emitted when a user agent session is going to be terminated in
|
||
|
the internal session store of the application router. Emitted either when
|
||
|
the user agent session is timed-out or when `logoutEndpoint` was requested.
|
||
|
|
||
|
*__Note:__ Central logout is an asynchronous process. The order in which
|
||
|
the backend and the application router sessions are invalidated, is not
|
||
|
guaranteed.*
|
||
|
|
||
|
Parameters:
|
||
|
* `session` - session object
|
||
|
* `id` - session identifier, immutable
|
||
|
|
||
|
## Example
|
||
|
There may be many various options, how the application router extension
|
||
|
decides to store sessions exposed via the session management API. The example
|
||
|
below assumes a `SessionDataAccessObject` to be implemented by the extension
|
||
|
developer and to have the following API:
|
||
|
|
||
|
### Methods:
|
||
|
|
||
|
* `sessionDataAccessObject.create` - `function(session, timeout)`
|
||
|
* `sessionDataAccessObject.update` - `function(sessionId, timeout)`
|
||
|
* `sessionDataAccessObject.delete` - `function(sessionId)`
|
||
|
* `sessionDataAccessObject.load` - `function()`
|
||
|
|
||
|
### Events:
|
||
|
|
||
|
#### `create`
|
||
|
|
||
|
Parameters:
|
||
|
|
||
|
* `sessionId` - session identifier
|
||
|
* `session` - session object serialized to string
|
||
|
* `timeout` - timestamp, when session should expire
|
||
|
* `callback` - function to be called after session is stored in
|
||
|
internal session storage
|
||
|
|
||
|
#### `update`
|
||
|
|
||
|
Parameters:
|
||
|
|
||
|
* `sessionId` - session identifier
|
||
|
* `session` - session object serialized to string
|
||
|
* `timeout` - timestamp, when session should be expired
|
||
|
* `callback` - function to be called after session is stored in
|
||
|
internal session storage
|
||
|
|
||
|
#### `delete`
|
||
|
|
||
|
Parameters:
|
||
|
|
||
|
* `sessionId` - session identifier
|
||
|
|
||
|
#### `load`
|
||
|
|
||
|
Parameters:
|
||
|
|
||
|
* `sessions[]` - array of objects
|
||
|
* `id` - session identifier
|
||
|
* `session` - session object serialized to string
|
||
|
* `timeout` - timestamp, when session should expire
|
||
|
|
||
|
```js
|
||
|
var ar = new require('@sap/approuter')();
|
||
|
var dao = new SessionDataAccessObject();
|
||
|
|
||
|
dao.on('load', function (data) {
|
||
|
|
||
|
ar.start({
|
||
|
getSessionSecret: function getSessionSecret() {
|
||
|
return process.env.SESSION_SECRET;
|
||
|
}
|
||
|
}, function() {
|
||
|
var store = ar.getSessionStore();
|
||
|
var defaultTimeout = store.getDefaultSessionTimeout();
|
||
|
|
||
|
// AppRouter -> Persistence
|
||
|
ar.on('login', function(session) {
|
||
|
dao.create(session, defaultTimeout);
|
||
|
});
|
||
|
ar.on('update', function(sessionId, timeout) {
|
||
|
dao.update(sessionId, timeout);
|
||
|
});
|
||
|
ar.on('logout', function(sessionId) {
|
||
|
dao.delete(sessionId);
|
||
|
});
|
||
|
|
||
|
// Load Initial Data
|
||
|
data.forEach(function(item) {
|
||
|
store.set(item.id, item.session, item.timeout);
|
||
|
});
|
||
|
|
||
|
// Persistence -> AppRouter
|
||
|
dao.on('create', store.set);
|
||
|
dao.on('update', store.set);
|
||
|
dao.on('delete', store.destroy);
|
||
|
});
|
||
|
|
||
|
});
|
||
|
|
||
|
dao.load();
|
||
|
```
|
||
|
|
||
|
## Performance
|
||
|
|
||
|
*__Note:__ The `update` event of the application router may be potentially
|
||
|
triggered thousands of times a second. It is recommended to throttle or
|
||
|
debounce calls to the external storage to reduce network and CPU
|
||
|
consumption.*
|
||
|
|
||
|
Here is an example of a throttled `dao.update()`, where the latest change
|
||
|
will be persisted in the external storage no more than once in `500ms` for
|
||
|
the same session.
|
||
|
|
||
|
```js
|
||
|
// Throttled update
|
||
|
update(sessionId, timeout) {
|
||
|
var dao = this;
|
||
|
var sessionStore = this._sessionStore;
|
||
|
if(typeof timeout === 'undefined') {
|
||
|
if (!this.updateTimers[sessionId]) {
|
||
|
this.updateTimers[sessionId] = setTimeout(function() {
|
||
|
dao.updateTimers[sessionId] = null;
|
||
|
}, 500);
|
||
|
sessionStore.get(sessionId, function(err, session) {
|
||
|
dao._saveSession(sessionId, session)
|
||
|
});
|
||
|
}
|
||
|
} else {
|
||
|
if (!this.timeoutTimers[sessionId]) {
|
||
|
this.timeoutTimers[sessionId] = setTimeout(function() {
|
||
|
dao.timeoutTimers[sessionId] = null;
|
||
|
}, 500);
|
||
|
sessionStore.getSessionTimeout(sessionId, function(err, timeout) {
|
||
|
dao._saveTimeout(sessionId, timeout)
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
And here is an example of a debounced `dao.update()`, where the latest
|
||
|
change will be persisted in the external storage only if there were no other
|
||
|
changes during the last `500ms` for the same session.
|
||
|
|
||
|
```js
|
||
|
// Debounced update
|
||
|
update(sessionId, timeout) {
|
||
|
var dao = this;
|
||
|
var sessionStore = this._sessionStore;
|
||
|
if(typeof timeout === 'undefined') {
|
||
|
if (this.updateTimers[sessionId]) {
|
||
|
clearTimeout(this.updateTimers[sessionId]);
|
||
|
}
|
||
|
this.updateTimers[sessionId] = setTimeout(function() {
|
||
|
sessionStore.get(sessionId, function(err, session) {
|
||
|
dao._saveSession(sessionId, session)
|
||
|
});
|
||
|
}, 500);
|
||
|
} else {
|
||
|
if (this.timeoutTimers[sessionId]) {
|
||
|
clearTimeout(this.timeoutTimers[sessionId]);
|
||
|
}
|
||
|
this.timeoutTimers[sessionId] = setTimeout(function() {
|
||
|
sessionStore.getSessionTimeout(sessionId, function(err, timeout) {
|
||
|
dao._saveTimeout(sessionId, timeout)
|
||
|
});
|
||
|
}, 500);
|
||
|
}
|
||
|
}
|
||
|
```
|
||
|
|
||
|
To understand the difference between throttling and debouncing, let's
|
||
|
consider an example, where requests for the same session come every
|
||
|
`100ms` for `1sec`. In case of `500ms` debouncing, changes will be
|
||
|
persisted one time. In case of `500ms` throttling, changes will be
|
||
|
persisted two times. Without any optimisation, changes will be
|
||
|
persisted ten times.
|
||
|
|
||
|
## Custom Storage Driver
|
||
|
|
||
|
It is possible to use your own driver. In order to do that, user shall inject its own implementation of a store.
|
||
|
|
||
|
The class shall implement the following interface:
|
||
|
```typescript
|
||
|
|
||
|
interface UserCustomStore {
|
||
|
|
||
|
// delete all sessions
|
||
|
clear(): Promise<void>;
|
||
|
|
||
|
// remove <sessionId> session
|
||
|
destroy(sessionId : string): Promise<void>;
|
||
|
|
||
|
// retrieve <sessionId> session
|
||
|
get(sessionId : string): Promise<object | null>;
|
||
|
|
||
|
// number of sessions
|
||
|
length(): Promise<number>;
|
||
|
|
||
|
// get <sessionId> expiration
|
||
|
ttl(sessionId : string): Promise<number>;
|
||
|
|
||
|
// set <sessionId> data to <session> with <timeout> expiration
|
||
|
set(sessionId: string, session: object, timeout: number): Promise<void>;
|
||
|
|
||
|
// check if session <sessionId> exists
|
||
|
exists(sessionId: string): boolean;
|
||
|
|
||
|
// update existing session <sessionId> expiration to <timeout>
|
||
|
resetTimer(sessionId: string, timeout: number);
|
||
|
}
|
||
|
```
|
||
|
|
||
|
In addition, the file should include a method to get an instance of the store, for example:
|
||
|
```typescript
|
||
|
let store;
|
||
|
module.exports.getStore = () => {
|
||
|
if (!store) {
|
||
|
store = new UserCustomStore();
|
||
|
}
|
||
|
return store;
|
||
|
};
|
||
|
```
|
||
|
|
||
|
see [Redis store](../lib/utils/redis-store.js) for example
|
||
|
|
||
|
In order for app router to use it, user shall set ```externalStoreFilePath``` property in the ```EXT_SESSION_MGT``` env variable with the path to the storage.
|
||
|
The application router will use this path to require your storage.
|
||
|
|
||
|
The application router uses the defaultRetryTimeout and the backOffMultiplier properties in the EXT_SESSION_MGT environment variable to determine the Redis pattern for automatic retries of failed operations.
|
||
|
|
||
|
For example:
|
||
|
```json
|
||
|
{
|
||
|
"instanceName": "approuter-redis",
|
||
|
"storageType": "redis",
|
||
|
"sessionSecret": "someuniquesessionsecret",
|
||
|
"externalStoreFilePath": "./src/storage/my-special-storage",
|
||
|
"defaultRetryTimeout": 10000,
|
||
|
"backOffMultiplier": 10
|
||
|
}
|
||
|
```
|
||
|
|
||
|
## Credentials Structure when using Redis
|
||
|
|
||
|
Redis store can be used on both CF and Kyma, however the external session managment expects to receive the credentials in a certain structure,
|
||
|
similar to the structure of the CF redis service instance.
|
||
|
If you're using Kyma, create a [K8s secret](https://kubernetes.io/docs/tasks/configmap-secret/managing-secret-using-config-file/#create-the-config-file) that includes the credentials in the following structure:
|
||
|
```json
|
||
|
{
|
||
|
"cluster_mode": "(Mandatory) boolean",
|
||
|
"tls": "(Mandatory) boolean",
|
||
|
"ca_base64": "(Optional) string",
|
||
|
"sentinel_nodes": "(Optional) Array of objects of hostname and port, e.g '[{hostname: 127.0.0.1, port: 26543}]'",
|
||
|
"uri": "(Mandatory if using sentinel_nodes) string",
|
||
|
"password": "(Mandatory) string",
|
||
|
"hostname": "(Mandatory) string",
|
||
|
"port": "string"
|
||
|
}
|
||
|
```
|