aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGuilhem Moulin <guilhem@fripost.org>2018-09-02 04:57:06 +0200
committerGuilhem Moulin <guilhem@fripost.org>2018-09-02 04:57:06 +0200
commitc3af385908866291109afb8cf8779da555a9922a (patch)
tree026c391d83c32e99af4332ab99ca91541ee56717
parenta0d7989835c98e9f0cb30a732e434d6b180afae4 (diff)
Simple login screen.
-rw-r--r--INSTALL49
-rw-r--r--config.ini59
-rw-r--r--lib/Fripost.pm114
-rw-r--r--lib/Fripost/Session.pm122
-rw-r--r--lib/Fripost/Util.pm90
-rwxr-xr-xpurge58
-rw-r--r--run.psgi297
-rw-r--r--static/css/fripost.css48
-rw-r--r--templates/html/login.html77
-rw-r--r--templates/html/overview.html26
10 files changed, 940 insertions, 0 deletions
diff --git a/INSTALL b/INSTALL
new file mode 100644
index 0000000..b600e95
--- /dev/null
+++ b/INSTALL
@@ -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;
diff --git a/purge b/purge
new file mode 100755
index 0000000..32f178c
--- /dev/null
+++ b/purge
@@ -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 &middot; 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> &copy; 2012-2018 Fripost
+ (<a href="https://git.fripost.org/fripost-panel/">source</a>)
+ &ndash; <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 &middot; 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>