#!/usr/bin/perl

# SPDX-License-Identifier: ISC

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

our $VERSION = '1.008';

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__

=encoding UTF-8

=head1 NAME

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

=head1 VERSION

version 1.008

=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 L<nginx|https://nginx.org/> web server
with the B<saslauthd> daemon from L<Cyrus SASL|https://www.cyrusimap.org/sasl/>.
The service supports Basic authentication and verifies users with LDAP, PAM or
other mechanisms supported by saslauthd.  Authentication requests are
forwarded from nginx with the C<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 the users belong to.  Defaults to the empty string.  The SASL
realm is not the authentication realm.

=back

=head2 NGINX

Use the C<auth_request> directive to enable authentication.  Set the
C<X-Realm> header to the authentication realm.  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>.

  touch /etc/saslauthd.conf
  chmod 0600 /etc/saslauthd.conf

Add your LDAP settings.

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

=head3 OPERATING SYSTEMS

=head4 DEBIAN AND UBUNTU

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

  START=yes
  MECHANISMS="ldap"
  MECH_OPTIONS=""
  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

=head4 FEDORA

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

  MECH=ldap
  FLAGS="-c"

Read the saslauthd(8) manual page for information on how to run the saslauthd
daemon unprivileged as user B<saslauth>.

  chgrp saslauth /etc/saslauthd.conf
  chmod 0640 /etc/saslauthd.conf

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 MAGEIA

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

  SASL_AUTHMECH=ldap
  SASLAUTHD_OPTS="-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>.

  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

=head3 START SASLAUTHD

Enable and start the service.

  systemctl enable saslauthd.service
  systemctl restart saslauthd.service

=head3 AUTHENTICATION TEST

Check your setup with B<testsaslauthd>.

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

=head2 SELINUX

Put the B<nginx-auth-saslauthd> daemon into the web server's SELinux context
and allow the daemon to use SASL.

  semanage fcontext -a -t httpd_exec_t /usr/local/sbin/nginx-auth-saslauthd
  restorecon /usr/local/sbin/nginx-auth-saslauthd
  setsebool -P httpd_use_sasl on

=head2 SYSTEMD

Install the package B<libnss-systemd> on Debian-based systems.

Users, who do not use C<systemd> in F<nsswitch.conf>, need to omit
C<DynamicUser> from the service file and create a system user.

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

Create F</etc/systemd/system/nginx-auth-saslauthd.service>.  The example below
is for Debian-based systems.  On RPM-based systems, the group is B<nginx>
instead of B<www-data>.  Use the supplementary group B<saslauth> on operating
systems from Red Hat.  Other systems may not require a supplementary group to
communicate with the saslauthd daemon.

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

  [Service]
  Type=simple
  DynamicUser=yes
  User=nginx-auth
  Group=www-data
  SupplementaryGroups=sasl
  RuntimeDirectory=nginx-auth
  RuntimeDirectoryMode=0750
  UMask=0007
  ExecStart=/usr/local/sbin/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
  ProtectProc=invisible
  ProcSubset=pid
  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

Start the service.

  systemctl --now enable nginx-auth-saslauthd.service

Test the running service.

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

=head1 DEPENDENCIES

Requires Mojolicious 7.27 or later and the saslauthd daemon from L<Cyrus
SASL|https://www.cyrusimap.org/sasl/>.

=head1 INCOMPATIBILITIES

None known.

=head1 SEE ALSO

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

=head1 AUTHOR

Andreas Vögele E<lt>voegelas@cpan.orgE<gt>

=head1 BUGS AND LIMITATIONS

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

=head1 LICENSE AND COPYRIGHT

Copyright 2017-2022 Andreas Vögele

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
