Skip to content

Commit

Permalink
Support multiple sessions in a single browser (#8496)
Browse files Browse the repository at this point in the history
* Support multiple sessions in a single browser, one per company

This adds support for multiple logins in the same browser on a single
server. The logins can be different companies with the same user,
the same company with different users or different users with different
companies.

Closes #6352
  • Loading branch information
ehuelsmann authored Nov 16, 2024
1 parent 3b52cb9 commit 2ee3f15
Show file tree
Hide file tree
Showing 13 changed files with 282 additions and 192 deletions.
10 changes: 5 additions & 5 deletions UI/src/store/configTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const configStoreTemplate = {
},
actions: {
async initialize() {
const response = await fetch(`/erp/api/v0/${this.url}`, {
const response = await fetch(`./erp/api/v0/${this.url}`, {
method: "GET"
});

Expand All @@ -28,7 +28,7 @@ export const configStoreTemplate = {
}
},
async add(adding) {
const response = await fetch(`/erp/api/v0/${this.url}`, {
const response = await fetch(`./erp/api/v0/${this.url}`, {
method: "POST",
headers: {
"Content-Type": "application/json"
Expand All @@ -48,7 +48,7 @@ export const configStoreTemplate = {
},
async del(id) {
const warehouse = this.getById(id);
const response = await fetch(`/erp/api/v0/${this.url}/${id}`, {
const response = await fetch(`./erp/api/v0/${this.url}/${id}`, {
method: "DELETE",
headers: {
"If-Match": warehouse._meta.ETag
Expand All @@ -71,7 +71,7 @@ export const configStoreTemplate = {
}
const warehouse = this.items[index];
if (!warehouse || !warehouse._meta || warehouse._meta.invalidated) {
const response = await fetch(`/erp/api/v0/${this.url}/${id}`, {
const response = await fetch(`./erp/api/v0/${this.url}/${id}`, {
method: "GET"
});

Expand Down Expand Up @@ -101,7 +101,7 @@ export const configStoreTemplate = {
},
async save(id, data) {
const warehouse = this.getById(id);
const response = await fetch(`/erp/api/v0/${this.url}/${id}`, {
const response = await fetch(`./erp/api/v0/${this.url}/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Expand Down
2 changes: 1 addition & 1 deletion UI/src/store/sessionUser.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const useSessionUserStore = defineStore("sessionUser", {
},
actions: {
async initialize() {
const response = await fetch("/erp/api/v0/session", {
const response = await fetch("./erp/api/v0/session", {
method: "GET"
});

Expand Down
2 changes: 1 addition & 1 deletion cpanfile
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,11 @@ requires 'PGObject::Type::ByteString', '1.2.3';
requires 'PGObject::Util::DBMethod', '1.1.0';
requires 'PGObject::Util::DBAdmin', '1.6.2';
requires 'Plack', '1.0031';
requires 'Plack::App::File';
requires 'Plack::Builder';
requires 'Plack::Builder::Conditionals';
requires 'Plack::Middleware::ConditionalGET';
requires 'Plack::Middleware::ReverseProxy';
requires 'Plack::Middleware::Static';
requires 'Plack::Request';
requires 'Plack::Request::WithEncoding';
requires 'Plack::Util';
Expand Down
52 changes: 52 additions & 0 deletions doc/adr/0026-resource-locking-over-stateless-http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 0026 Resource locking over stateless HTTP

Date: During 1.3 cycle (before 2013)

## Status

Accepted

## Context

LedgerSMB - being a multi-user (web) application - needs to protect some
of its resources (eg., transactions and batches) against concurrent
modification. Applications with direct database access or client/server
applications with persistent connections can use long-running database
connections with record locking to protect against concurrent modification.

As a web-application, LedgerSMB is a type of client/server application,
with only short-lived connections to the server (and in a load-balancing
scenario even "with short-lived connections to *a* server"). In LedgerSMB's
design, database connections are tied to the HTTP request/response cycle. As
a consequence, the connections don't last long enough to protect resources
from concurrent modification.

Since the traditional method of record locking does not help LedgerSMB to
protect against concurrent modification of resources, it needs a different
mechanism, which - like more traditional applications - is tied to the
logical duration of access. The logical duration of access would be the
period over which the user is working on the resource. The resource should
be freed after the user completes or given a period of inactivity.

## Decision

LedgerSMB will have a `session` concept for each user logged into the
application. This session persists in the database as long as the user
is logged in. Extended periods of inactivity will lead to the session
expiring and cleanup. The session will also be cleaned up when the user
logs out.

The session will be an anchor for other - session scoped - concepts,
including but not limited to locked transactions and batches.

## Consequences

1. There will be a `session` table with records for each logged in user
2. The table will have a clean up mechanism to remove expired sessions
3. Resources which need to be locked, will have a link to the session in
which they are locked, signalling a "logical" lock
4. There needs to be a mechanism to clean up the links to expired sessions
(eg. `ON DELETE SET NULL`)

## Annotations

31 changes: 12 additions & 19 deletions doc/conf/webserver/apache-vhost.conf
Original file line number Diff line number Diff line change
Expand Up @@ -44,35 +44,28 @@ NameVirtualHost *:443
# configuration files (those ending in '.conf'), don't exist
RewriteRule "\.conf$" - [R=404,L]

RewriteCond "%{REQUEST_FILENAME}" -f
RewriteRule .* "-" [L]

RewriteCond "%{REQUEST_URI}" "/[a-z0-9A-Z]+(/.*)"
RewriteCond "%{DOCUMENT_ROOT}%1" -f
RewriteRule .* "-" [L]

# Rewrite non-static content to the application backend
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443"
RewriteCond "%{REQUEST_FILENAME}" !-f
RewriteCond "%{REQUEST_FILENAME}" !-d
RewriteRule "^/(.*)" "http://STARMAN_HOST:5762/$1" [P]
ProxyPassReverse "/" "http://STARMAN_HOST:5762/"
# If you host LedgerSMB on a path other than "/" in exposed
# URL space, you need
#ProxyPassReverseCookiePath / /THE-EXPOSED-PATH

# Timeout settings, if you receive reverse proxy
# timeout or 50x errors, especially during company
# Timeout settings, if you receive reverse proxy
# timeout or 50x errors, especially during company
# database creation, try to raise these setting.
Timeout 300
ProxyTimeout 300

<IfModule mod_headers.c>
# Serve gzip CSS and JS, if they exist and if the client accepts gzip
RewriteCond "%{HTTP:Accept-encoding}" "gzip"
RewriteCond "%{REQUEST_FILENAME}\.gz" -s
RewriteRule "^(.*)\.(css|js)" "$1\.$2\.gz" [QSA]

# Fix types and prevent double gzip
RewriteRule "\.css\.gz$" "-" [T=text/css,E=no-gzip:1]
RewriteRule "\.js\.gz$" "-" [T=text/javascript,E=no-gzip:1]

<FilesMatch "(\.js\.gz|\.css\.gz)$">
Header append Content-Encoding gzip

# Force proxies to store gzip and css/js files separately
Header append Vary Accept-Encoding
</FilesMatch>
</IfModule>
</VirtualHost>
20 changes: 11 additions & 9 deletions doc/conf/webserver/nginx-github.conf
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,23 @@ http {
return 301 /login.pl;
}

# JS & CSS
location ~* \.(js|css)$ {
add_header Pragma "public";
add_header Cache-Control "public, must-revalidate, proxy-revalidate"; # Production
expires 7d; # Indicate that the resource can be cached for 1 week # Production
try_files $uri =404;
location / {
try_files $uri @strippedprefix @starman;
}

location / {
location @strippedprefix {
rewrite ^/([a-z0-9A-Z]+)/(.*) /$2 break;
}

location @starman {
proxy_pass http://lsmb:5762;
proxy_read_timeout 300;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
proxy_pass http://lsmb:5762;
}
}
}
20 changes: 13 additions & 7 deletions doc/conf/webserver/nginx-vhost.conf
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,23 @@ server {
return 404;
}
location / {
try_files $uri $uri/ @starman;
try_files $uri @strippedprefix @starman;
}

location @strippedprefix {
rewrite ^/([a-z0-9A-Z]+)/(.*) /$2 break;
}

location @starman {
# If you changed the port in the Starman service file, change it here too
proxy_pass http://localhost:5762;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://localhost:5762;
proxy_read_timeout 300;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-Proto $scheme;
}

}
51 changes: 42 additions & 9 deletions lib/LedgerSMB/Middleware/SessionStorage.pm
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ use warnings;
use parent qw ( Plack::Middleware );

use Cookie::Baker;
use HTTP::Status qw( HTTP_BAD_REQUEST );
use Plack::Request;
use Plack::Util;
use Plack::Util::Accessor
qw( cookie cookie_path domain duration inner_serialize secret store force_create );
use Session::Storage::Secure;
use String::Random;
use URI;

use LedgerSMB::PSGI::Util;

Expand All @@ -43,10 +45,6 @@ use LedgerSMB::PSGI::Util;
Implements C<Plack::Component->prepare_app()>.
=head2 $self->call($env)
Implements C<Plack::Middleware->call()>.
=cut

sub prepare_app {
Expand All @@ -58,25 +56,60 @@ sub prepare_app {
$self->store( $store );
}

=head2 $self->call($env)
Implements C<Plack::Middleware->call()>.
=cut

sub _prefix_path {
my ($self, $env) = @_;
my $token = $env->{'lsmb.session'}->{company_path} ?
$env->{'lsmb.session'}->{company_path} . '/' : '';

if ($self->cookie_path) {
return $self->cookie_path . $token;
}
else {
my $path = ($env->{SCRIPT_NAME} =~ s|[^/]*$||r);
$path .= $token
if $path !~ m/$token/;

return $path;
}
}

sub call {
my $self = shift;
my ($env) = @_;
my $req = Plack::Request->new($env);

my $referer = $req->headers->header( 'referer' );
my $referer_uri = $referer ? URI->new( $referer ) : undef;
my $referer_user = $referer_uri ? $referer_uri->query_param( 'user' ) : '';
my $cookie = $req->cookies->{$self->cookie};
my $session = (not $self->force_create) ? $self->store->decode($cookie) : undef;
$session->{csrf_token} //= String::Random->new->randpattern('.' x 23);
my $session_user = $session ? $session->{login} : '';

if ($referer_user
and $session_user
and $session_user ne $referer_user) {
return [ HTTP_BAD_REQUEST,
[ 'Content-Type' => 'text/plain' ],
[ "Browser expects session for user '$referer_user', ",
"but session for user '$session_user' found" ]
];
}

my $secure = defined($env->{HTTPS}) && $env->{HTTPS} eq 'ON';
my $path =
$self->cookie_path //
LedgerSMB::PSGI::Util::cookie_path($env->{SCRIPT_NAME});
$session->{csrf_token} //= String::Random->new->randpattern('.' x 23);
$env->{'lsmb.session'} = $session;
my $secure = defined($env->{HTTPS}) && $env->{HTTPS} eq 'ON';
return Plack::Util::response_cb(
$self->app->($env), sub {
my $res = shift;

if (! $self->inner_serialize) {
my $path = $self->_prefix_path( $env );
my $_cookie_attributes = {
value => $self->store->encode(
$env->{'lsmb.session'},
Expand Down
Loading

0 comments on commit 2ee3f15

Please sign in to comment.