#!/usr/bin/perl

use Mojolicious::Lite;
use Mojo::IOLoop;
use Mojo::Util qw(b64_decode decode encode quote);
use English qw(-no_match_vars);

our $VERSION = '1.003';

local $PROGRAM_NAME = 'nginx-auth-saslauthd';

sub find_mux {
  my @paths = (
    '/run/saslauthd/mux',       '/var/run/saslauthd/mux',
    '/var/state/saslauthd/mux', '/var/sasl2/mux',
  );
  return ((grep {-S} @paths), '/run/sasl2/mux')[0];
}

my $config = plugin Config => {
  file    => $ENV{MOJO_CONFIG} || '/etc/nginx/auth-saslauthd.conf',
  default => {
    path    => find_mux,
    timeout => 10,
    service => 'nginx',
    realm   => q{},
  }
};

sub auth_cyrus {
  my ($login, $pw, $cb) = @_;

  my $service = $config->{service};
  my $timeout = $config->{timeout};
  my $path    = $config->{path};
  my $realm   = $config->{realm};

  my $cred = pack 'n/a*n/a*n/a*n/a*', encode('UTF-8', $login),
    encode('UTF-8', $pw), encode('UTF-8', $service), encode('UTF-8', $realm);

  my $bytes = q{};

  Mojo::IOLoop->client(
    {path => $path, timeout => $timeout},
    sub {
      my ($loop, $err, $stream) = @_;

      if ($err) {
        $cb->("$path: $err", 'NO');
        return;
      }

      $stream->on(read => sub { $bytes .= $_[1] });

      $stream->on(
        close => sub {
          my $reply = unpack 'n/a*', $bytes;
          $cb->(undef, $reply);
        }
      );

      $stream->on(
        error => sub {
          my ($stream, $err) = @_;
          $cb->("$path: $err", 'NO');
        }
      );

      $stream->timeout($timeout);
      $stream->write($cred);
    }
  );
  return;
}

sub authorized {
  my ($c, $reply) = @_;

  return $c->render(status => 200, text => $reply, format => 'txt');
}

sub unauthorized {
  my ($c, $reply) = @_;

  my $realm = $c->req->headers->header('X-Realm') // 'Restricted';
  $c->res->headers->www_authenticate(
    'Basic realm=' . quote($realm) . ', charset="UTF-8"');
  $c->res->headers->cache_control('no-cache');
  return $c->render(status => 401, text => $reply, format => 'txt');
}

sub error {
  my ($c, $err) = @_;

  return $c->render(status => 503, text => $err, format => 'txt');
}

get '/auth-basic' => sub {
  my $c = shift;

  my $auth = $c->req->headers->authorization // q{};
  if (substr($auth, 0, 6) eq 'Basic ') {
    my $cred = b64_decode(substr $auth, 6) // q{};
    $cred = decode('UTF-8', $cred) // decode('ISO-8859-15', $cred) // q{};
    my ($login, $pw) = split /:/, $cred, 2;
    if (defined $login && $login ne q{} && defined $pw && $pw ne q{}) {
      $c->render_later;
      my $tx = $c->tx;
      auth_cyrus(
        $login, $pw,
        sub {
          my ($err, $reply) = @_;
          return if $tx->is_finished;
          return error($c, $err) if defined $err;
          return error($c, 'no reply from saslauthd') if !defined $reply;
          return unauthorized($c, $reply) if substr($reply, 0, 2) ne 'OK';
          return authorized($c, $reply);
        }
      );
      return;
    }
  }

  return unauthorized($c, 'Unauthorized');
};

app->hook(
  after_dispatch => sub {
    my $c = shift;
    $c->res->headers->remove('Server');
  }
);

app->start;
__END__

=head1 NAME

nginx-auth-saslauthd - Verify web users with Basic authentication and saslauthd

=head1 VERSION

This documentation refers to nginx-auth-saslauthd version 1.002.

=head1 USAGE

    location /private/ {
        auth_request /auth;
    }

    location = /auth {
        internal;
        proxy_pass http://unix:/run/nginx-auth/saslauthd.sock:/auth-basic;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header X-Realm "Restricted";
    }

=head1 DESCRIPTION

B<nginx-auth-saslauthd> interfaces the B<nginx> web server with the
B<saslauthd> daemon.  The service supports Basic authentication and verifies
users with LDAP, PAM or other mechanisms supported by B<saslauthd>.
Authentication requests are forwarded from B<nginx> with the B<auth_request>
directive.

=head1 CONFIGURATION

Create the file F</etc/nginx/auth-saslauthd.conf> if the default values do not
fit.

    {
        path    => '/run/saslauthd/mux',
        timeout => 10,
        service => 'nginx',
        realm   => '',
    };

=over 4

=item path

The path to the communications socket. Defaults to F</run/saslauthd/mux>,
F</run/sasl2/mux>, F</var/run/saslauthd/mux>, F</var/state/saslauthd/mux> or
F</var/sasl2/mux>, depending on the platform.

=item timeout

A timeout when writing to and reading from the communications socket. Defaults
to 10 seconds.

=item service

The SASL service name. Defaults to "nginx".

=item realm

The SASL realm to which the users belong. Defaults to the empty string.

=back

=head2 NGINX

Use the B<auth_request> directive to enable authentication. Set the
B<X-Realm> header to the realm for Basic authentication. The realm must only
contain ASCII characters.

    location /private/ {
        auth_request /auth;
    }

    location = /auth {
        internal;
        proxy_pass http://unix:/run/nginx-auth/saslauthd.sock:/auth-basic;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header X-Realm "Restricted";
    }

B<nginx> can be configured to cache authentication requests, but the
credentials will be stored in cleartext.

    http {
        ...
        proxy_cache_path /var/cache/nginx/auth_cache keys_zone=auth_cache:1m;

        server {
            ...
            location = /auth {
                ...
                proxy_cache auth_cache
                proxy_cache_key "$http_authorization";
                proxy_cache_valid 200 10m;
            }
        }
    }

=head2 SASLAUTHD

If you use LDAP, create F</etc/saslauthd.conf>.

    ldap_servers: ldap://ad1.example.com ldap://ad2.example.com
    ldap_start_tls: yes
    ldap_tls_cacert_file: /etc/ssl/certs/EXAMPLE-ADS-CA.pem
    ldap_tls_check_peer: yes
    ldap_search_base: OU=Users,DC=EXAMPLE,DC=COM
    ldap_filter: (sAMAccountName=%U)
    ldap_bind_dn: CN=saslauthd,OU=Users,DC=EXAMPLE,DC=COM
    ldap_password: secret

Credential caching can be enabled by passing the B<-c> switch to B<saslauthd>.

=head3 OPERATING SYSTEMS

=head4 FEDORA

Install the B<cyrus-sasl> package. Configure the authentication daemon in
F</etc/sysconfig/saslauthd>. Enable the B<saslauthd.service> with
B<systemctl>.

    MECH=ldap
    FLAGS="-c"

If you use PAM instead of LDAP, create F</etc/pam.d/nginx>.

    #%PAM-1.0
    auth    include system-auth
    account include system-auth

=head4 OPENSUSE

Install the B<cyrus-sasl-saslauthd> package. Configure the authentication
daemon in F</etc/sysconfig/saslauthd>. Enable the B<saslauthd.service> with
B<systemctl>.

    SASLAUTHD_AUTHMECH=ldap
    SASLAUTHD_PARAMS="-c"

If you use PAM instead of LDAP, create F</etc/pam.d/nginx>.

    #%PAM-1.0
    auth    include common-auth
    account include common-account

=head4 UBUNTU

Install the B<sasl2-bin> package. Enable and configure the authentication
daemon in F</etc/default/saslauthd>.

    START=yes
    MECHANISMS="ldap"
    OPTIONS="-c -m /run/saslauthd"

If you use PAM instead of LDAP, create F</etc/pam.d/nginx>.

    #%PAM-1.0
    @include common-auth
    @include common-account

=head3 AUTHENTICATION TEST

Start the B<saslauthd> daemon and check your setup with B<testsaslauthd>.

    unset HISTFILE
    /usr/sbin/testsaslauthd -s nginx -u $USER -p 'your password'

=head2 SYSTEMD

Create a new system user.

    useradd -r -M -d /nonexistent -s /usr/sbin/nologin -U nginx-auth

Create F</etc/systemd/system/nginx-auth-saslauthd.service> and enable the
service with B<systemctl>.

The below example is for Ubuntu. On other systems the group may be B<nginx>
instead of B<www-data> and no supplementary group may be required to
communicate with the B<saslauthd> daemon.

    [Unit]
    Description=Basic authentication with saslauthd
    After=network.target saslauthd.service
    Before=nginx.service

    [Service]
    Type=simple
    User=nginx-auth
    Group=www-data
    SupplementaryGroups=sasl
    RuntimeDirectory=nginx-auth
    RuntimeDirectoryMode=0770
    UMask=0007
    ExecStart=/usr/local/bin/nginx-auth-saslauthd daemon -m production \
              -l http+unix://%%2Frun%%2Fnginx-auth%%2Fsaslauthd.sock
    CapabilityBoundingSet=
    DevicePolicy=closed
    IPAddressDeny=any
    LockPersonality=yes
    MemoryDenyWriteExecute=yes
    NoNewPrivileges=yes
    ProtectSystem=strict
    ProtectHome=yes
    PrivateNetwork=yes
    PrivateTmp=yes
    PrivateDevices=yes
    PrivateUsers=yes
    ProtectHostname=yes
    ProtectClock=yes
    ProtectKernelTunables=yes
    ProtectKernelModules=yes
    ProtectKernelLogs=yes
    ProtectControlGroups=yes
    RestrictAddressFamilies=AF_UNIX
    RestrictNamespaces=yes
    RestrictRealtime=yes
    RestrictSUIDSGID=yes
    RemoveIPC=yes
    SystemCallArchitectures=native
    SystemCallFilter=@system-service
    SystemCallFilter=~@privileged
    SystemCallFilter=~@resources

    [Install]
    WantedBy=multi-user.target

Test the running service with B<curl>.

    curl -v --unix-socket /run/nginx-auth/saslauthd.sock \
         -H "X-Realm: hello, world" http://localhost/auth-basic

=head1 DEPENDENCIES

Requires Mojolicious 8.0 or later and the saslauthd daemon from Cyrus SASL.

=head1 INCOMPATIBILITIES

None known.

=head1 SEE ALSO

Mojolicious, Mojolicious::Guides::Cookbook, saslauthd(8),
L<https://nginx.org/en/docs/http/ngx_http_auth_request_module.html>

=head1 AUTHOR

Andreas Voegele E<lt>voegelas@cpan.orgE<gt>

=head1 BUGS AND LIMITATIONS

Basic authentication doesn't encrypt the credentials. Protect your site with
HTTPS.

Please report any bugs to the author.

=head1 LICENSE AND COPYRIGHT

Copyright 2017-2021 Andreas Voegele

Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

=cut
