diff options
author | Guilhem Moulin <guilhem@fripost.org> | 2018-09-02 04:57:06 +0200 |
---|---|---|
committer | Guilhem Moulin <guilhem@fripost.org> | 2018-09-02 04:57:06 +0200 |
commit | c3af385908866291109afb8cf8779da555a9922a (patch) | |
tree | 026c391d83c32e99af4332ab99ca91541ee56717 | |
parent | a0d7989835c98e9f0cb30a732e434d6b180afae4 (diff) |
Simple login screen.
-rw-r--r-- | INSTALL | 49 | ||||
-rw-r--r-- | config.ini | 59 | ||||
-rw-r--r-- | lib/Fripost.pm | 114 | ||||
-rw-r--r-- | lib/Fripost/Session.pm | 122 | ||||
-rw-r--r-- | lib/Fripost/Util.pm | 90 | ||||
-rwxr-xr-x | purge | 58 | ||||
-rw-r--r-- | run.psgi | 297 | ||||
-rw-r--r-- | static/css/fripost.css | 48 | ||||
-rw-r--r-- | templates/html/login.html | 77 | ||||
-rw-r--r-- | templates/html/overview.html | 26 |
10 files changed, 940 insertions, 0 deletions
@@ -0,0 +1,49 @@ +These core Perl modules are required + + Digest::SHA + MIME::Base64 + +The library depends on these extra modules: + + Config::Tiny + Net::IDN::Encode + Net::LDAP + IO::Socket::SSL (for ldaps:// URIs) + +The PSGI application depends on these additional modules: + + Authen::SASL + Cache::FastMmap + CHI + Crypt::URandom + HTML::Template + CGI + Plack::Builder + Plack::Session::State::Cookie + Plack::Session::Store::Cache + URI::Escape + +On Debian GNU/Linux systems, the following packages cover the +dependencies: + + libconfig-tiny-perl + libnet-idn-encode-perl + libnet-ldap-perl + libio-socket-ssl-perl + + libauthen-sasl-perl + libcache-fastmmap-perl + libchi-perl + libcrypt-urandom-perl + libhtml-template-perl + libcgi-pm-perl + libplack-perl + libplack-middleware-session-perl + liburi-perl + + +For development, one can use plackup(1p) as follows to launch the PSGI +application: + + plackup -E development -R ./config.ini \ + --host 127.0.0.1 --port 5000 ./run.psgi diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..c8d841b --- /dev/null +++ b/config.ini @@ -0,0 +1,59 @@ +[ldap] + +# LDAP URI (RFC 2255), of the form "SCHEME://[HOST[:PORT]]". +# Default: ldapi:// +uri = ldaps://ldap.fripost.org + +# ALGO=FINGERPRINT pinning for ldaps:// URIs, where ALGO is the digest +# algorithm name (such as "sha256") and FINGERPRINT is the Base64 +# encoded Subject Public Key Information (SPKI) fingerprint, which can +# be obtained (for SHA-256) by dumping the leaf X.509 certificate to +# +# openssl x509 -noout -pubkey +# | openssl pkey -pubin -outform DER +# | openssl dgst -sha256 -binary | base64 +# +ssl-fingerprint = sha256=5G5kcfM2TwIYPin0PsnqIQaMnBo8DcB+9Ie8LtVlmOs= + +# Distinguished Name suffix for the account entries +suffix = ou=virtual,dc=fripost-test,dc=org + +# Map a session ID (%s) to its authentication identity +session-authcID = %s/sessions + +# Map a session ID (%s) to its authentication Distinguished Name. On +# the slapd side, the "authz-regex" must map "session-authcid" to +# "session-authcDN". +session-authcDN = cn=%s,ou=sessions,dc=fripost-test,dc=org + + +[www] + +# Default domain for the login form. +default-domain = fripost.org + +# Base64-encoding of the key used to sign (HMAC-SHA256) CSRF tokens. +# Must be unique and kept secret. A suitable key can be generated with +# +# head -c32 /dev/urandom | base64 +# +# If left empty (the default), then a random key is generated when the +# program starts, and lost when it exits. +#hmac-key = <<FIXME>> + +# Directory where to find HTML templates. (Default: "./templates/html".) +#templates-directory = /path/to/html/templates + +# HTTP session cookie attributes +cookie-domain = +cookie-path = / +cookie-httponly = true +cookie-secure = false + +# Cache directory (created with mode 0700 minus umask) for +# CHI::Driver::FastMmap +cache-directory = /tmp/fripost-panel.d + +# Amount of time after which the session expires, unless it is used +# meanwhile. (Default: 3600.) +cache-expires = 900 diff --git a/lib/Fripost.pm b/lib/Fripost.pm new file mode 100644 index 0000000..93531f9 --- /dev/null +++ b/lib/Fripost.pm @@ -0,0 +1,114 @@ +#---------------------------------------------------------------------- +# Fripost tools - backend library +# Copyright © 2012-2018 Fripost +# Copyright © 2012-2018 Guilhem Moulin <guilhem@fripost.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +#---------------------------------------------------------------------- + +package Fripost v0.0.1; +use warnings; +use strict; + +use Net::LDAP (); +use Net::LDAP::Constant qw/LDAP_SUCCESS LDAP_INVALID_CREDENTIALS/; +use Net::LDAP::Extension::WhoAmI (); +use Net::LDAP::Util "escape_dn_value"; +use Net::IDN::Encode "domain_to_ascii"; + + +######################################################################## +# + +# new(OPTION => VALUE) +# Create a new Fripost object. +sub new($%) { + my $class = shift; + my %conf = @_; + my $uri = $conf{ldap}->{uri} // die; + my $fpr = $conf{ldap}->{"ssl-fingerprint"}; + + my %args = ( raw => qr/(?:^fripostOpenPGPKeyring\b|;binary$)/i ); + $args{verify} //= ($uri =~ /\Aldaps:\/\// and not defined $fpr) ? "require" : "none"; + my $ldap = Net::LDAP::->new($uri, %args) // die "LDAP connection to $uri failed"; + + if (defined $fpr) { + my $cert = $ldap->certificate->peer_certificate; + die("Aborting LDAP connection to $uri: fingerprint mismatch") + unless $fpr->( Net::SSLeay::X509_get_X509_PUBKEY($cert) ); + } + undef $conf{ldap}->{suffix} if ($conf{ldap}->{suffix} // "") eq ""; + bless {_ldap => $ldap, _config => \%conf}, $class; +} + +sub DESTROY { + my $ldap = shift->{_ldap} // return; + $ldap->unbind(); +} + +# croak(FORMAT, [ARG..]) +# Format the message and throw an exception. +sub croak($$@) { + my $self = shift; + my $format = shift; + if (defined (my $throw = $self->{_config}->{onerror})) { + $throw->($format, @_); + } else { + $format =~ s/[A-Z]<(%\p{PosixAlpha})>/$1/g; # Remove all markup + die sprintf("Error: ".$format, @_); + } +} + +# login(USER@DOMAIN, PASSWORD) +# Login to the LDAP backend (with a simple bind). LDAP errors other +# than 49 (INVALID_CREDENTIALS) are treated as internal backend +# errors and shown to the user. +sub login($$$) { + my ($self, $username, $password) = @_; + + # convert IDNA domain names to ASCII + my ($l, $d) = ($1, $2) + if $username =~ /\A(\p{ASCII}*)[\@\N{U+FE6B}\N{U+FF20}](.*)\z/; + eval { $d = domain_to_ascii($d) if defined $d and $d =~ /\P{ASCII}/ }; + $self->croak("Invalid username: C<%s>\n", $username // "") + if $@ or ($l // "") eq "" or ($d // "") eq ""; + + my $dn = "fvl=".escape_dn_value($l).",fvd=".escape_dn_value($d); + $dn .= ",".$self->{_config}->{ldap}->{suffix} + if defined $self->{_config}->{ldap}->{suffix}; + + my $r = $self->{_ldap}->bind($dn, password => $password); + if ($r->code == LDAP_INVALID_CREDENTIALS) { + $self->croak("Invalid username or password.\n"); + } elsif ($r->code != LDAP_SUCCESS) { + $self->croak("LDAP error code %i: %s\n", $r->code, $r->error); + } +} + +# whoami() +# Perform the LDAP "Who Am I?" (RFC 4532) extended operation. +sub whoami($) { + my $self = shift; + + my $dse = $self->{_ldap}->root_dse(); + $self->croak("LDAP server doesn't support \"Who am I?\" operation.") + unless $dse->supported_extension("1.3.6.1.4.1.4203.1.11.3"); + + my $r = $self->{_ldap}->who_am_i(); + $self->croak("LDAP error code %i: %s\n", $r->code, $r->error) + unless $r->code == LDAP_SUCCESS; + return $r->response(); +} + +1; diff --git a/lib/Fripost/Session.pm b/lib/Fripost/Session.pm new file mode 100644 index 0000000..888385f --- /dev/null +++ b/lib/Fripost/Session.pm @@ -0,0 +1,122 @@ +#---------------------------------------------------------------------- +# Fripost admin panel - ephemeral sessions +# Copyright © 2018 Fripost +# Copyright © 2018 Guilhem Moulin <guilhem@fripost.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +#---------------------------------------------------------------------- + +package Fripost::Session v0.0.1; +use warnings; +use strict; + +use Authen::SASL (); +use Net::LDAP::Constant qw/LDAP_SUCCESS LDAP_ALREADY_EXISTS/; +use Net::LDAP::Extension::Refresh (); +use Net::LDAP::Util "escape_dn_value"; + +use Crypt::URandom "urandom"; + +use Fripost (); + + +# create(Fripost object) +# Create a new ephemeral session from a Fripost object, and return +# suitable credentials for later SASL proxy authorization. +sub create($$) { + my ($class, $fp) = @_; + + # don't base64-encode but hex-encode as the commonName is case-insensitive + my $id = unpack("H*", urandom(16)); + my $dn = sprintf($fp->{_config}->{ldap}->{"session-authcDN"}, + escape_dn_value($id)); + # hex-encode the password too since we can't have NUL bytes in the SASL packet + my $password = unpack("H*", urandom(16)); + + my $authzid = $fp->whoami() // die; + die "Invalid identity: $authzid\n" unless $authzid =~ /\Adn:/; + + my @attrs = (objectClass => [ qw/organizationalRole simpleSecurityObject dynamicObject/ ]); + # libsasl2 requires {CLEARTEXT} passwords, even for PLAIN, cf. + # https://openldap.org/lists/openldap-technical/201310/msg00007.html + # (not a big deal here though since our shared secrets are internal + # and ephemeral) + push @attrs, userPassword => ( "{CLEARTEXT}" . $password ); + + my $r = $fp->{_ldap}->add($dn, attrs => \@attrs); + if ($r->code == LDAP_ALREADY_EXISTS) { + # try to delete the entry (we're not allowed to modify existing entries) + my $r2 = $fp->{_ldap}->delete($dn); + $r = $fp->{_ldap}->add($dn, attrs => \@attrs) + if $r2->code == LDAP_SUCCESS; + } + $fp->croak("LDAP error code %i: %s\n", $r->code, $r->error) + unless $r->code == LDAP_SUCCESS; + + my %creds = (authcid => $id, password => $password, authzid => $authzid); + bless \%creds, $class; +} + +# authenticate(CREDENTIALS, OPTION => VALUE, ..) +# Create a new Fripost object and return it after authentication +# (using SASL proxy authorization with the ephemeral credentials). +# If the "refresh" is set (the default), then TTL value of the entry +# on the backup is refreshed. +sub authenticate($%) { + my $creds = shift; + my %conf = @_; + + my $refresh = delete $conf{refresh} // 1; + my $authcid = sprintf($conf{ldap}->{"session-authcID"} // "%s", + $creds->{authcid}); + + my $sasl = Authen::SASL::->new( mechanism => "PLAIN", callback => { + user => $authcid + , pass => $creds->{password} + , authname => $creds->{authzid} + }) or die "Creation of Authen::SASL object failed"; + + my $fp = Fripost::->new(%conf); + my $r = $fp->{_ldap}->bind(undef, sasl => $sasl); + $fp->croak("LDAP error code %i: %s\n", $r->code, $r->error) + unless $r->code == LDAP_SUCCESS; + + if ($refresh) { + my $dn = sprintf($conf{ldap}->{"session-authcDN"} // "%s", + escape_dn_value($creds->{authcid})); + my $ttl = $conf{www}->{"cache-expires"}; + $r = $fp->{_ldap}->refresh(entryName => $dn, requestTtl => $ttl); + $fp->croak("LDAP error code %i: %s\n", $r->code, $r->error) + unless $r->code == LDAP_SUCCESS; + } + return $fp; +} + +# authenticate(CREDENTIALS, OPTION => VALUE, ..) +# Create a new Fripost object, authenticate (using SASL proxy +# authorization), and delete the entry on the LDAP backend. +sub destroy($%) { + my $creds = shift; + my %conf = @_; + + my $dn = sprintf($conf{ldap}->{"session-authcDN"} // "%s", + escape_dn_value($creds->{authcid})); + + my $fp = authenticate($creds, %conf, refresh => 0); + my $r = $fp->{_ldap}->delete($dn); + $fp->croak("LDAP error code %i: %s\n", $r->code, $r->error) + unless $r->code == LDAP_SUCCESS; +} + +1; diff --git a/lib/Fripost/Util.pm b/lib/Fripost/Util.pm new file mode 100644 index 0000000..6fa55a5 --- /dev/null +++ b/lib/Fripost/Util.pm @@ -0,0 +1,90 @@ +#---------------------------------------------------------------------- +# Fripost utils +# Copyright © 2018 Fripost +# Copyright © 2018 Guilhem Moulin <guilhem@fripost.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +#---------------------------------------------------------------------- + +package Fripost::Util v0.0.1; +use warnings; +use strict; + +use Config::Tiny (); +use MIME::Base64 qw/encode_base64 decode_base64/; + +use Exporter "import"; +BEGIN { + our @EXPORT_OK = qw/read_config session_cache/; +} + + +# read_config() +# Read the configuration file. +sub read_config() { + my $filename = "./config.ini"; + my $h = Config::Tiny::->read($filename, "utf8"); + die Config::Tiny::->errstr(), "\n" unless defined $h; + + my $ldap = $h->{ldap} // die "Missing [ldap] section"; + $ldap->{uri} //= "ldapi://"; + + # replace "ssl-fingerprint" with a function taking an SPKI and + # verifying its fingerprint + my $fpr = delete $ldap->{"ssl-fingerprint"} + if $ldap->{uri} =~ /\Aldaps:\/\//; + if (defined $fpr) { + die "Invalid value: $fpr" unless $fpr =~ s/\A([A-Za-z0-9]+)=//; + my $algo = $1; + + my $digest = decode_base64($fpr); + die "Invalid base64 value: $fpr\n" + # decode_base64() silently ignores invalid characters so we + # re-encode the output to validate it + unless encode_base64($digest, "") eq $fpr and $fpr ne ""; + + require "Net/SSLeay.pm"; + my $type = Net::SSLeay::EVP_get_digestbyname($algo) or + die "Can't find MD value for name '$algo'"; + $ldap->{"ssl-fingerprint"} = sub($) { + my $pkey = shift // return 0; + return (Net::SSLeay::EVP_Digest($pkey, $type) eq $digest) ? 1 : 0; + }; + } + + $h->{www} //= {}; + $h->{www}->{"cache-expires"} //= "3600"; + return %$h; +} + + +# session_cache(%CONFIG) +# Define a new CHI object for the server-side session store. +sub session_cache(%) { + my %www_config = @_; + require "CHI.pm"; + + my %cache_opts; + $cache_opts{root_dir} = $www_config{"cache-directory"}; + $cache_opts{dir_create_mode} = 0700; + $cache_opts{namespace} = "fripost"; + + CHI->new( + driver => "FastMmap" # use Cache::FastMmap + , %cache_opts + , expires_in => $www_config{"cache-expires"} + ); +} + +1; @@ -0,0 +1,58 @@ +#!/usr/bin/perl -T + +#---------------------------------------------------------------------- +# Fripost's admin panel - session prune job +# Copyright © 2018 Fripost +# Copyright © 2018 Guilhem Moulin <guilhem@fripost.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +#---------------------------------------------------------------------- + +use strict; +use warnings; + +use lib "lib"; +use Fripost::Util "read_config"; + +my %CONFIG = read_config(); +$CONFIG{www} //= {}; + +my $cache = Fripost::Util::session_cache(%{$CONFIG{www}}); + +if (-t \*STDOUT) { + my $count = 0; + my $count_expired = 0; + require "POSIX.pm"; + + foreach my $k ($cache->get_keys()) { + $count++; + my $obj = $cache->get_object($k) // next; + if ($obj->is_expired) { + $count_expired++; + my $date = POSIX::strftime("%Y-%m-%d %T %z", + localtime($obj->expires_at())); + printf " - %-32s expired on %s\n", $k, $date; + } + } + print "$count keys founds (incl. $count_expired expired keys)\n"; + print "Purging...\n" +} + +$cache->purge(); + +if (-t \*STDOUT) { + my $count = 0; + $count++ foreach $cache->get_keys(); + print "$count keys left after purge()\n"; +} diff --git a/run.psgi b/run.psgi new file mode 100644 index 0000000..4ad719e --- /dev/null +++ b/run.psgi @@ -0,0 +1,297 @@ +#!/usr/bin/perl -T + +#---------------------------------------------------------------------- +# Fripost's admin panel - PSGI application +# Copyright © 2012-2018 Fripost +# Copyright © 2012-2018 Guilhem Moulin <guilhem@fripost.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +#---------------------------------------------------------------------- + +use strict; +use warnings; + +use Digest::SHA "hmac_sha256"; +use MIME::Base64 qw/encode_base64 decode_base64 encode_base64url/; + +use Crypt::URandom "urandom"; +use HTML::Template (); +use Plack::Builder (); +use Plack::Session::Store::Cache (); +use Plack::Session::State::Cookie (); +use URI::Escape "uri_unescape"; +use CGI "escapeHTML"; # XXX use HTML::Escape::escape_html() instead + +use lib "lib"; +use Fripost (); +use Fripost::Session (); +use Fripost::Util "read_config"; + +my %CONFIG = read_config(); +$CONFIG{www} //= {}; +$CONFIG{www}->{"templates-directory"} //= "./templates/html"; + +# Generate or decode the HMAC key +if ((my $v = $CONFIG{www}->{"hmac-key"} // "") eq "") { + print STDERR "Generating new random key for HMAC-SHA256...\n"; + $CONFIG{www}->{"hmac-key"} = urandom(32); +} else { + my $key = decode_base64($v); + # decode_base64() silently ignores invalid characters so we + # re-encode the output to validate it + die "Invalid base64 value: $v\n" if encode_base64($key, "") ne $v; + $CONFIG{www}->{"hmac-key"} = $key; +} + +# Where to redirect authenticated users +my $WELCOME_PAGE = "/overview"; + + +######################################################################## +# + +# throw(FORMAT, [ARG, ..]) +# Format the error message in $ERRSTR and throw an exception. +our $ERRSTR; +sub throw($;@) { + my $format = shift; + chomp $format; + $format =~ s#C<(%\p{PosixAlpha})>#<tt>$1</tt>#g; + $ERRSTR = sprintf($format, map {escapeHTML($_)} @_); + die "I'm a bug, please report"; # we should never see this +} + +# csrf_token_validate(REQ) +# Throw an exception if the "csrf-token" POST parameter isn't a valid +# CSRF token. +sub csrf_token_validate($) { + my $req = shift; + my $sid = $req->session_options->{id}; + my $key = $CONFIG{www}->{"hmac-key"} // die; + my $token = $req->body_parameters->get("csrf-token"); + + throw("CSRF token validation failed.") unless + defined $sid and defined $token and + # tokens are valid for 1 day + $token =~ s/\A([0-9]+)-// and (time - $1) <= 86400 and + $token eq encode_base64url( hmac_sha256($1 . $sid, $key) ); +} + +# render(REQ, TEMPLATE, [VAR1 => VAL1, ..]) +# Render an HTML template, embedding a CSRF token and possibly other +# variables. +sub render($$%) { + my $sid = shift->session_options->{id} // die; + my $filename = shift; + my %params = @_; + + my $tmpl = HTML::Template::->new( + path => $CONFIG{www}->{"templates-directory"} + , filename => $filename + , cache => $ENV{PLACK_ENV} eq "development" ? 0 : 1 + , loop_context_vars => 1 + ); + + my $timestamp = time; + my $digest = hmac_sha256($timestamp . $sid, $CONFIG{www}->{"hmac-key"}); + $params{CSRF_TOKEN} = $timestamp ."-". encode_base64url($digest); + + $tmpl->param(%params); + return [ 200, ["Content-Type" => "text/html"], [$tmpl->output] ]; +} + +# redirect(CODE => LOCATION) +# Produce a redirection page with the given code and location. +sub redirect($$) { + my ($code, $location) = @_; + return [ $code, ["Location" => $location], [] ]; +} + + +######################################################################## +# Configure middleware for session cache and cookie +# +$ENV{PLACK_ENV} //= "development"; +my $builder = Plack::Builder::->new(); +{ + my %cookie_opts; + foreach (qw/domain path/) { + my $v = $CONFIG{www}->{"cookie-$_"}; + $cookie_opts{$_} = $v if defined $v and $v ne ""; + } + foreach (qw/httponly secure/) { + my $v = $CONFIG{www}->{"cookie-$_"} // continue; + $cookie_opts{$_} = lc $v eq "true" ? 1 : lc $v eq "false" ? 0 : undef; + } + + my $cache = Fripost::Util::session_cache(%{$CONFIG{www}}); + $builder->add_middleware( "Session" + , store => Plack::Session::Store::Cache::->new(cache => $cache) + , state => Plack::Session::State::Cookie::->new( + # fresh session IDs have 192 bits of entropy (32 characters in base64) + sid_generator => sub() { encode_base64url(urandom(24), "") } + , sid_validator => qr/\A[A-Za-z0-9\-_]{32}\z/ + , %cookie_opts + ) + ) +} + +if ($ENV{PLACK_ENV} eq "development") { + # don't rely on system CSS/JS/fonts libraries in dev mode + my %resources = qw{ + css/bootstrap.min.css https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css + css/font-awesome.min.css https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css + js/bootstrap.min.js https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js + js/jquery.min.js https://code.jquery.com/jquery-3.3.1.min.js + }; + warn "WARNING: Development mode, with cross-site requests!\n" + ."In production, serve static files locally using a reverse proxy instead.\n"; + foreach my $r (keys %resources) { + $builder->mount( "/static/$r" => sub($) { redirect(303 => $resources{$r}) } ); + } + # serve our own existing static files + $builder->add_middleware("Static", path => qr{\A/static/}, pass_through => 1); +} + + +######################################################################## +# Mount the login page +# +$builder->mount("/login" => sub($) { + my $req = Plack::Request::->new(shift); + my %tmpl_params = (ALERT => ""); + local $ERRSTR = undef; + + if ($req->method eq "POST") { + my $r = eval { + csrf_token_validate($req); + # don't connect to the backend if the CSRF token is invalid + my $fp = Fripost::->new(%CONFIG, onerror => \&throw); + + # append the default domain to bare usernames + my $username = $req->body_parameters->get("username"); + $username .= "@" . $CONFIG{www}->{"default-domain"} if + defined $username and defined $CONFIG{www}->{"default-domain"} + and $username !~ /[\@\N{U+FE6B}\N{U+FF20}]/; + + # try to authenticate now + $fp->login($username, $req->body_parameters->get("password")); + + # $creds contains its own authentication ID; we're can't use + # the session ID because the new one isn't available until + # after the function exits + $req->session->{credentials} = Fripost::Session::->create($fp); + + # login was successful; get a new session ID now, to protect + # against session fixation attacks + $req->session_options->{change_id}++; + }; + if (defined $r) { + my $goto = $req->query_parameters->get("goto"); + $goto = uri_unescape($goto) if defined $goto; + return redirect(302 => $goto // $WELCOME_PAGE); + } elsif (not defined $ERRSTR) { + die "Internal error: ", $@; + } else { + delete $req->session->{credentials}; + $tmpl_params{ALERT} = $ERRSTR; + $tmpl_params{ALERT_TYPE} = "danger"; + } + } + elsif (defined $req->session->{credentials}) { + # already logged in + my $goto = $req->query_parameters->get("goto"); + $goto = uri_unescape($goto) if defined $goto; + return redirect(302 => $goto // $WELCOME_PAGE); + } + else { + if ($req->query_parameters->get("signoff")) { + $tmpl_params{ALERT_TYPE} = "success"; + $tmpl_params{ALERT} = "You successfully logged out."; + } + } + + $tmpl_params{DOMAIN} = $CONFIG{www}->{"cookie-domain"}; + render( $req, "login.html", %tmpl_params ); +}); + + +######################################################################## +# Mount the logout page +# +$builder->mount("/logout" => sub($) { + my $req = Plack::Request::->new(shift); + local $ERRSTR = undef; + + if ($req->method eq "POST") { + # log out out via POST and validate the CSRF token + if (defined (eval { csrf_token_validate($req) })) { + + # silently try to destroy the session on the LDAP backend + Fripost::Session::destroy($req->session->{credentials}, + %CONFIG, onerror => sub($@) {} + ); + + # force the session to expire in our local cache + delete $req->session->{credentials}; + $req->session_options->{expire} = 1; + + return redirect( 302 => "/login?signoff=1" ); + } + elsif (not defined $ERRSTR) { + die "Internal error: ", $@; + } + } + + # refuse to log out otherwise, redirect to the welcome page instead + return redirect( 302 => $WELCOME_PAGE ); +}); + + +######################################################################## +# Mount the overview page +# +$builder->mount($WELCOME_PAGE => sub($) { + my $req = Plack::Request::->new(shift); + + return redirect( 302 => "/login" ) + # skip "?goto=..." for the default page + unless defined $req->session->{credentials}; + + local $ERRSTR = undef; + my %tmpl_params; + + my $r = eval { + # auth using the session credentials (refresh the entry) + Fripost::Session::authenticate($req->session->{credentials}, + %CONFIG, onerror => \&throw + ); + }; + if (defined $r) { + $tmpl_params{AUTHZID} = $req->session->{credentials}->{authzid}; + } elsif (not defined $ERRSTR) { + die "Internal error: ", $@; + } else { + # something went wrong... + } + + render( $req, "overview.html", %tmpl_params ); +}); + + +######################################################################## +# Add a catch-all: redirect all other segments to the welcome page. +# +$builder->mount( "/" => sub($) { redirect( 303 => $WELCOME_PAGE ) } ); +$builder->to_app(); diff --git a/static/css/fripost.css b/static/css/fripost.css new file mode 100644 index 0000000..3b1d242 --- /dev/null +++ b/static/css/fripost.css @@ -0,0 +1,48 @@ +html, +body { + height: 100%; + /* The html and body elements cannot have any padding or margin. */ +} + +body { + display: flex; + flex-direction: column; + justify-content: center; +} +body > header.lead { + font-size: 3rem; +} +a.brand:link, +a.brand:visited, +a.brand:hover, +a.brand:active { + color: black; + text-decoration: none; +} +.login { + max-width: 30em; + margin-right: auto; + margin-left: auto; +} +.login form { + width: 100%; +} +.login form .input-group { + margin-bottom: 1ex; +} +.login .well { + margin-bottom: 0; +} +.login .well + .well-footer { + margin-top: .5ex; +} +body > footer { + background-color: #f5f5f5; + position: absolute; + width: 100%; + bottom: 0; +} +body > footer.container { + padding: 1ex 2ex 0 2ex; + width: 100%; +} diff --git a/templates/html/login.html b/templates/html/login.html new file mode 100644 index 0000000..0e88e83 --- /dev/null +++ b/templates/html/login.html @@ -0,0 +1,77 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>Sign in · Fripost Admin Panel</title> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link href="/static/css/bootstrap.min.css" rel="stylesheet"> + <link href="/static/css/font-awesome.min.css" rel="stylesheet"> + <link href="/static/css/fripost.css" rel="stylesheet"> + </head> + + <body> + <header class="container pagination-center lead text-center"> + <a class="brand" href="https://fripost.org/"><b>fripost</b> | demokratisk e-post</a> + </header> + + <div class="login"> + <TMPL_IF NAME=ALERT> + <div id="alert" class="alert alert-<TMPL_VAR NAME=ALERT_TYPE> alert-dismissible fade in" role="alert"> + <button type="button" class="close" data-dismiss="alert" aria-label="Close"> + <span aria-hidden="true"><i class="fa fa-close icon-small"></i></span> + </button> + <p><TMPL_VAR NAME=ALERT ESCAPE=NONE></p> + </div> + </TMPL_IF> + + <div class="well well-lg"> + <form method="post" action="/login<TMPL_IF NAME=GOTO>?goto=<TMPL_VAR NAME=GOTO ESCAPE=URL></TMPL_IF>"> + <div class="input-group input-group-lg"> + <span class="input-group-addon"><i class="fa fa-user"></i></span> + <input + name="username" + type="text" + class="form-control" + placeholder="Username or email address" + required + > + </div> + <div class="input-group input-group-lg"> + <span class="input-group-addon"><i class="fa fa-lock"></i></span> + <input + name="password" + type="password" + class="form-control" + placeholder="Passphrase" + required + > + </div> + <input type="hidden" name="csrf-token" value="<TMPL_VAR NAME=CSRF_TOKEN>"> + <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button> + </form> + </div> + <div class="well-footer text-center small"> + Not a member? <a href="https://fripost.org/medlemskap/">Join us!</a> + </div> + </div> + + <footer class="container"> + <div class="row"> + <div class="col-md-8 text-muted small"> + <p>Your browser needs to accept sessions + cookies<TMPL_IF NAME=DOMAIN> for <tt><TMPL_VAR NAME=DOMAIN ESCAPE=HTML></tt></TMPL_IF>. + We also recommend enabling JavaScript for better usability.</p> + </div> + <div class="col-md-4 text-muted text-right small"> + <p><a href="https://git.fripost.org/fripost-panel/tree/COPYING">Copyright</a> © 2012-2018 Fripost + (<a href="https://git.fripost.org/fripost-panel/">source</a>) + – <a href="https://fripost.org/kontakt/">contact</a></p> + </div> + </div> + </footer> + + <!-- Place JS at the end of the document so the pages load faster --> + <script src="/static/js/jquery.min.js"></script> + <script src="/static/js/bootstrap.min.js"></script> + </body> +</html> diff --git a/templates/html/overview.html b/templates/html/overview.html new file mode 100644 index 0000000..fbf1b9d --- /dev/null +++ b/templates/html/overview.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>Account overview · Fripost Admin Panel</title> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <link href="/static/css/bootstrap.min.css" rel="stylesheet"> + <link href="/static/css/font-awesome.min.css" rel="stylesheet"> + <link href="/static/css/fripost.css" rel="stylesheet"> + </head> + + <body> + <div class="container"> + <tt><TMPL_VAR NAME=AUTHZID ESCAPE=HTML></tt> + <br/> + <form method="post" action="/logout"> + <input type="hidden" name="csrf-token" value="<TMPL_VAR NAME=CSRF_TOKEN>"> + <input type="submit" value="logout"> + </form> + </div> + + <!-- Place JS at the end of the document so the pages load faster --> + <script src="/static/js/jquery.min.js"></script> + <script src="/static/js/bootstrap.min.js"></script> + </body> +</html> |