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 /run.psgi | |
| parent | a0d7989835c98e9f0cb30a732e434d6b180afae4 (diff) | |
Simple login screen.
Diffstat (limited to 'run.psgi')
| -rw-r--r-- | run.psgi | 297 | 
1 files changed, 297 insertions, 0 deletions
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();  | 
