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