From 52c4582f7b431e69fd8eae67b97bbb330614d0b5 Mon Sep 17 00:00:00 2001 From: Keval Kapdee Date: Fri, 12 Jan 2024 20:45:14 +0000 Subject: [PATCH] feat: Auth - OAuth2 (Dovecot PassDB) (#3480) Co-authored-by: Brennan Kinney <5098581+polarathene@users.noreply.github.com> --- .gitignore | 1 + CHANGELOG.md | 7 +++ Dockerfile | 7 +++ README.md | 1 + docs/content/config/advanced/auth-oauth2.md | 69 +++++++++++++++++++++ docs/content/config/environment.md | 24 ++++++- docs/mkdocs.yml | 1 + mailserver.env | 12 ++++ target/dovecot/10-auth.conf | 1 + target/dovecot/auth-oauth2.conf.ext | 7 +++ target/dovecot/dovecot-oauth2.conf.ext | 4 ++ target/scripts/start-mailserver.sh | 5 ++ target/scripts/startup/setup.d/oauth2.sh | 11 ++++ target/scripts/startup/variables-stack.sh | 6 ++ test/config/oauth2/provider.py | 56 +++++++++++++++++ test/files/auth/imap-oauth2-auth.txt | 4 ++ test/tests/serial/mail_with_oauth2.bats | 66 ++++++++++++++++++++ 17 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 docs/content/config/advanced/auth-oauth2.md create mode 100644 target/dovecot/auth-oauth2.conf.ext create mode 100644 target/dovecot/dovecot-oauth2.conf.ext create mode 100644 target/scripts/startup/setup.d/oauth2.sh create mode 100644 test/config/oauth2/provider.py create mode 100644 test/files/auth/imap-oauth2-auth.txt create mode 100644 test/tests/serial/mail_with_oauth2.bats diff --git a/.gitignore b/.gitignore index 50d22a22..79a4dc3c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ ################################################# .env +compose.override.yaml docs/site/ docker-data/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 382b9efa..e3881941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to this project will be documented in this file. The format > **Note**: Changes and additions listed here are contained in the `:edge` image tag. These changes may not be as stable as released changes. +### Features + + - **Authentication with OIDC / OAuth 2.0** 🎉 + - DMS now supports authentication via OAuth2 (_via `XOAUTH2` or `OAUTHBEARER` SASL mechanisms_) from capable services (_like Roundcube_). + - This does not replace the need for an `ACCOUNT_PROVISIONER` (`FILE` / `LDAP`), which is required for an account to receive or send mail. + - Successful authentication (_via Dovecot PassDB_) still requires an existing account (_lookup via Dovecot UserDB_). + ### Updates - **Tests**: diff --git a/Dockerfile b/Dockerfile index 65d818ab..2118622d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -108,6 +108,13 @@ EOF COPY target/rspamd/local.d/ /etc/rspamd/local.d/ COPY target/rspamd/scores.d/* /etc/rspamd/scores.d/ +# ----------------------------------------------- +# --- OAUTH2 ------------------------------------ +# ----------------------------------------------- + +COPY target/dovecot/auth-oauth2.conf.ext /etc/dovecot/conf.d +COPY target/dovecot/dovecot-oauth2.conf.ext /etc/dovecot + # ----------------------------------------------- # --- LDAP & SpamAssassin's Cron ---------------- # ----------------------------------------------- diff --git a/README.md b/README.md index 8918c2e9..e81d21ad 100644 --- a/README.md +++ b/README.md @@ -48,3 +48,4 @@ If you have issues, please search through [the documentation][documentation::web - Support for [LetsEncrypt](https://letsencrypt.org/), manual and self-signed certificates - A [setup script](https://docker-mailserver.github.io/docker-mailserver/latest/config/setup.sh) for easy configuration and maintenance - SASLauthd with LDAP authentication +- OAuth2 authentication (_via `XOAUTH2` or `OAUTHBEARER` SASL mechanisms_) diff --git a/docs/content/config/advanced/auth-oauth2.md b/docs/content/config/advanced/auth-oauth2.md new file mode 100644 index 00000000..963a6c2c --- /dev/null +++ b/docs/content/config/advanced/auth-oauth2.md @@ -0,0 +1,69 @@ +--- +title: 'Advanced | Basic OAuth2 Authentication' +--- + +## Introduction + +!!! warning "This is only a supplement to the existing account provisioners" + + Accounts must still be managed via the configured [`ACCOUNT_PROVISIONER`][env::account-provisioner] (FILE or LDAP). + + Reasoning for this can be found in [#3480][gh-pr::oauth2]. Future iterations on this feature may allow it to become a full account provisioner. + +[gh-pr::oauth2]: https://github.com/docker-mailserver/docker-mailserver/pull/3480 +[env::account-provisioner]: ../environment.md#account_provisioner + +The present OAuth2 support provides the capability for 3rd-party applications such as Roundcube to authenticate with DMS (dovecot) by using a token obtained from an OAuth2 provider, instead of passing passwords around. + +## Example (Authentik & Roundcube) + +This example assumes you have: + +- A working DMS server set up +- An Authentik server set up ([documentation](https://goauthentik.io/docs/installation/)) +- A Roundcube server set up (either [docker](https://hub.docker.com/r/roundcube/roundcubemail/) or [bare metal](https://github.com/roundcube/roundcubemail/wiki/Installation)) + +!!! example "Setup Instructions" + + === "1. Docker Mailserver" + Edit the following values in `mailserver.env`: + ```env + # ----------------------------------------------- + # --- OAUTH2 Section ---------------------------- + # ----------------------------------------------- + + # empty => OAUTH2 authentication is disabled + # 1 => OAUTH2 authentication is enabled + ENABLE_OAUTH2=1 + + # Specify the user info endpoint URL of the oauth2 provider + OAUTH2_INTROSPECTION_URL=https://authentik.example.com/application/o/userinfo/ + ``` + + === "2. Authentik" + 1. Create a new OAuth2 provider + 2. Note the client id and client secret + 3. Set the allowed redirect url to the equivalent of `https://roundcube.example.com/index.php/login/oauth` for your RoundCube instance. + + === "3. Roundcube" + Add the following to `oauth2.inc.php` ([documentation](https://github.com/roundcube/roundcubemail/wiki/Configuration)): + + ```php + $config['oauth_provider'] = 'generic'; + $config['oauth_provider_name'] = 'Authentik'; + $config['oauth_client_id'] = ''; + $config['oauth_client_secret'] = ''; + $config['oauth_auth_uri'] = 'https://authentik.example.com/application/o/authorize/'; + $config['oauth_token_uri'] = 'https://authentik.example.com/application/o/token/'; + $config['oauth_identity_uri'] = 'https://authentik.example.com/application/o/userinfo/'; + + // Optional: disable SSL certificate check on HTTP requests to OAuth server. For possible values, see: + // http://docs.guzzlephp.org/en/stable/request-options.html#verify + $config['oauth_verify_peer'] = false; + + $config['oauth_scope'] = 'email openid profile'; + $config['oauth_identity_fields'] = ['email']; + + // Boolean: automatically redirect to OAuth login when opening Roundcube without a valid session + $config['oauth_login_redirect'] = false; + ``` diff --git a/docs/content/config/environment.md b/docs/content/config/environment.md index c3c074fd..61e604aa 100644 --- a/docs/content/config/environment.md +++ b/docs/content/config/environment.md @@ -54,7 +54,15 @@ The Group ID assigned to the static vmail group for `/var/mail` (_Mail storage m Configures the provisioning source of user accounts (including aliases) for user queries and authentication by services managed by DMS (_Postfix and Dovecot_). -User provisioning via OIDC is planned for the future, see [this tracking issue](https://github.com/docker-mailserver/docker-mailserver/issues/2713). +!!! tip "OAuth2 Support" + + Presently DMS supports OAuth2 only as an supplementary authentication method. + + - A third-party service must provide a valid token for the user which Dovecot validates with the authentication service provider. To enable this feature reference the [OAuth2 configuration example guide][docs::auth::oauth2-config-guide]. + - User accounts must be provisioned to receive mail via one of the supported `ACCOUNT_PROVISIONER` providers. + - User provisioning via OIDC is planned for the future, see [this tracking issue](https://github.com/docker-mailserver/docker-mailserver/issues/2713). + +[docs::auth::oauth2-config-guide]: ./advanced/auth-oauth2.md - **empty** => use FILE - LDAP => use LDAP authentication @@ -716,10 +724,20 @@ Enable or disable `getmail`. - **5** => `getmail` The number of minutes for the interval. Min: 1; Max: 30; Default: 5. + +#### OAUTH2 + +##### ENABLE_OAUTH2 + +- **empty** => OAUTH2 authentication is disabled +- 1 => OAUTH2 authentication is enabled + +##### OAUTH2_INTROSPECTION_URL + +- => Specify the user info endpoint URL of the oauth2 provider (_eg: `https://oauth2.example.com/userinfo/`_) + #### LDAP - - ##### LDAP_START_TLS - **empty** => no diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 6441dffe..7df1acbe 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -142,6 +142,7 @@ nav: - 'Postfix': config/advanced/override-defaults/postfix.md - 'Modifications via Script': config/advanced/override-defaults/user-patches.md - 'LDAP Authentication': config/advanced/auth-ldap.md + - 'OAuth2 Authentication': config/advanced/auth-oauth2.md - 'Email Filtering with Sieve': config/advanced/mail-sieve.md - 'Email Gathering with Fetchmail': config/advanced/mail-fetchmail.md - 'Email Gathering with Getmail': config/advanced/mail-getmail.md diff --git a/mailserver.env b/mailserver.env index 0c2e1e40..c9793a99 100644 --- a/mailserver.env +++ b/mailserver.env @@ -419,6 +419,18 @@ ENABLE_GETMAIL=0 # The number of minutes for the interval. Min: 1; Max: 30. GETMAIL_POLL=5 +# ----------------------------------------------- +# --- OAUTH2 Section ---------------------------- +# ----------------------------------------------- + +# empty => OAUTH2 authentication is disabled +# 1 => OAUTH2 authentication is enabled +ENABLE_OAUTH2= + +# Specify the user info endpoint URL of the oauth2 provider +# Example: https://oauth2.example.com/userinfo/ +OAUTH2_INTROSPECTION_URL= + # ----------------------------------------------- # --- LDAP Section ------------------------------ # ----------------------------------------------- diff --git a/target/dovecot/10-auth.conf b/target/dovecot/10-auth.conf index f71289e9..260832fb 100644 --- a/target/dovecot/10-auth.conf +++ b/target/dovecot/10-auth.conf @@ -123,6 +123,7 @@ auth_mechanisms = plain login #!include auth-sql.conf.ext #!include auth-ldap.conf.ext !include auth-passwdfile.inc +#!include auth-oauth2.conf.ext #!include auth-checkpassword.conf.ext #!include auth-vpopmail.conf.ext #!include auth-static.conf.ext diff --git a/target/dovecot/auth-oauth2.conf.ext b/target/dovecot/auth-oauth2.conf.ext new file mode 100644 index 00000000..6096d1e4 --- /dev/null +++ b/target/dovecot/auth-oauth2.conf.ext @@ -0,0 +1,7 @@ +auth_mechanisms = $auth_mechanisms oauthbearer xoauth2 + +passdb { + driver = oauth2 + mechanisms = xoauth2 oauthbearer + args = /etc/dovecot/dovecot-oauth2.conf.ext +} diff --git a/target/dovecot/dovecot-oauth2.conf.ext b/target/dovecot/dovecot-oauth2.conf.ext new file mode 100644 index 00000000..6998ed08 --- /dev/null +++ b/target/dovecot/dovecot-oauth2.conf.ext @@ -0,0 +1,4 @@ +introspection_url = +# Dovecot defaults: +introspection_mode = auth +username_attribute = email diff --git a/target/scripts/start-mailserver.sh b/target/scripts/start-mailserver.sh index 2129b74a..1f352229 100755 --- a/target/scripts/start-mailserver.sh +++ b/target/scripts/start-mailserver.sh @@ -71,6 +71,11 @@ function _register_functions() { ;; esac + if [[ ${ENABLE_OAUTH2} -eq 1 ]]; then + _environment_variables_oauth2 + _register_setup_function '_setup_oauth2' + fi + if [[ ${ENABLE_SASLAUTHD} -eq 1 ]]; then _environment_variables_saslauthd _register_setup_function '_setup_saslauthd' diff --git a/target/scripts/startup/setup.d/oauth2.sh b/target/scripts/startup/setup.d/oauth2.sh new file mode 100644 index 00000000..20e9ffd1 --- /dev/null +++ b/target/scripts/startup/setup.d/oauth2.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +function _setup_oauth2() { + _log 'debug' 'Setting up OAUTH2' + + # Enable OAuth2 PassDB (Authentication): + sedfile -i -e '/\!include auth-oauth2\.conf\.ext/s/^#//' /etc/dovecot/conf.d/10-auth.conf + _replace_by_env_in_file 'OAUTH2_' '/etc/dovecot/dovecot-oauth2.conf.ext' + + return 0 +} diff --git a/target/scripts/startup/variables-stack.sh b/target/scripts/startup/variables-stack.sh index 2660ce89..fc38a39d 100644 --- a/target/scripts/startup/variables-stack.sh +++ b/target/scripts/startup/variables-stack.sh @@ -151,6 +151,12 @@ function __environment_variables_general_setup() { VARS[UPDATE_CHECK_INTERVAL]="${UPDATE_CHECK_INTERVAL:=1d}" } +function _environment_variables_oauth2() { + _log 'debug' 'Setting OAUTH2-related environment variables now' + + VARS[OAUTH2_INTROSPECTION_URL]="${OAUTH2_INTROSPECTION_URL:=}" +} + # This function handles environment variables related to LDAP. # NOTE: SASLAuthd and Dovecot LDAP support inherit these common ENV. function _environment_variables_ldap() { diff --git a/test/config/oauth2/provider.py b/test/config/oauth2/provider.py new file mode 100644 index 00000000..22fc8129 --- /dev/null +++ b/test/config/oauth2/provider.py @@ -0,0 +1,56 @@ +# OAuth2 mock service +# +# Dovecot will query this service with the token it was provided. +# If the session for the token is valid, a response provides an attribute to perform a UserDB lookup on (default: email). + +import json +import base64 +from http.server import BaseHTTPRequestHandler, HTTPServer + +# OAuth2.0 Bearer token (paste into https://jwt.io/ to check it's contents). +# You should never need to edit this unless you REALLY need to change the issuer. +token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vcHJvdmlkZXIuZXhhbXBsZS50ZXN0OjgwMDAvIiwic3ViIjoiODJjMWMzMzRkY2M2ZTMxMWFlNGFhZWJmZTk0NmM1ZTg1OGYwNTVhZmYxY2U1YTM3YWE3Y2M5MWFhYjE3ZTM1YyIsImF1ZCI6Im1haWxzZXJ2ZXIiLCJ1aWQiOiI4OU4zR0NuN1M1Y090WkZNRTVBeVhNbmxURFdVcnEzRmd4YWlyWWhFIn0.zuCytArbphhJn9XT_y9cBdGqDCNo68tBrtOwPIsuKNyF340SaOuZa0xarZofygytdDpLtYr56QlPTKImi-n1ZWrHkRZkwrQi5jQ-j_n2hEAL0vUToLbDnXYfc5q2w7z7X0aoCmiK8-fV7Kx4CVTM7riBgpElf6F3wNAIcX6R1ijUh6ISCL0XYsdogf8WUNZipXY-O4R7YHXdOENuOp3G48hWhxuUh9PsUqE5yxDwLsOVzCTqg9S5gxPQzF2eCN9J0I2XiIlLKvLQPIZ2Y_K7iYvVwjpNdgb4xhm9wuKoIVinYkF_6CwIzAawBWIDJAbix1IslkUPQMGbupTDtOgTiQ" + +# This is the string the user-facing client (e.g. Roundcube) should send via IMAP to Dovecot. +# We include the user and the above token separated by '\1' chars as per the XOAUTH2 spec. +xoauth2 = base64.b64encode(f"user=user1@localhost.localdomain\1auth=Bearer {token}\1\1".encode("utf-8")) +# If changing the user above, use the new output from the below line with the contents of the AUTHENTICATE command in test/test-files/auth/imap-oauth2-auth.txt +print("XOAUTH2 string: " + str(xoauth2)) + + +class HTTPRequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + auth = self.headers.get("Authorization") + if auth is None: + self.send_response(401) + self.end_headers() + return + if len(auth.split()) != 2: + self.send_response(401) + self.end_headers() + return + auth = auth.split()[1] + # Valid session, respond with JSON containing the expected `email` claim to match as Dovecot username: + if auth == token: + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({ + "email": "user1@localhost.localdomain", + "email_verified": True, + "sub": "82c1c334dcc6e311ae4aaebfe946c5e858f055aff1ce5a37aa7cc91aab17e35c" + }).encode("utf-8")) + else: + self.send_response(401) + self.end_headers() + +server = HTTPServer(('', 80), HTTPRequestHandler) +print("Starting server", flush=True) + +try: + server.serve_forever() +except KeyboardInterrupt: + print() + print("Received keyboard interrupt") +finally: + print("Exiting") diff --git a/test/files/auth/imap-oauth2-auth.txt b/test/files/auth/imap-oauth2-auth.txt new file mode 100644 index 00000000..825fabda --- /dev/null +++ b/test/files/auth/imap-oauth2-auth.txt @@ -0,0 +1,4 @@ +a0 NOOP See test/config/oauth2/provider.py to generate the below XOAUTH2 string +a1 AUTHENTICATE XOAUTH2 dXNlcj11c2VyMUBsb2NhbGhvc3QubG9jYWxkb21haW4BYXV0aD1CZWFyZXIgZXlKaGJHY2lPaUpTVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKb2RIUndPaTh2Y0hKdmRtbGtaWEl1WlhoaGJYQnNaUzUwWlhOME9qZ3dNREF2SWl3aWMzVmlJam9pT0RKak1XTXpNelJrWTJNMlpUTXhNV0ZsTkdGaFpXSm1aVGswTm1NMVpUZzFPR1l3TlRWaFptWXhZMlUxWVRNM1lXRTNZMk01TVdGaFlqRTNaVE0xWXlJc0ltRjFaQ0k2SW0xaGFXeHpaWEoyWlhJaUxDSjFhV1FpT2lJNE9VNHpSME51TjFNMVkwOTBXa1pOUlRWQmVWaE5ibXhVUkZkVmNuRXpSbWQ0WVdseVdXaEZJbjAuenVDeXRBcmJwaGhKbjlYVF95OWNCZEdxRENObzY4dEJydE93UElzdUtOeUYzNDBTYU91WmEweGFyWm9meWd5dGREcEx0WXI1NlFsUFRLSW1pLW4xWldySGtSWmt3clFpNWpRLWpfbjJoRUFMMHZVVG9MYkRuWFlmYzVxMnc3ejdYMGFvQ21pSzgtZlY3S3g0Q1ZUTTdyaUJncEVsZjZGM3dOQUljWDZSMWlqVWg2SVNDTDBYWXNkb2dmOFdVTlppcFhZLU80UjdZSFhkT0VOdU9wM0c0OGhXaHh1VWg5UHNVcUU1eXhEd0xzT1Z6Q1RxZzlTNWd4UFF6RjJlQ045SjBJMlhpSWxMS3ZMUVBJWjJZX0s3aVl2VndqcE5kZ2I0eGhtOXd1S29JVmluWWtGXzZDd0l6QWF3QldJREpBYml4MUlzbGtVUFFNR2J1cFREdE9nVGlRAQE= +a2 EXAMINE INBOX +a3 LOGOUT diff --git a/test/tests/serial/mail_with_oauth2.bats b/test/tests/serial/mail_with_oauth2.bats new file mode 100644 index 00000000..0d73bc54 --- /dev/null +++ b/test/tests/serial/mail_with_oauth2.bats @@ -0,0 +1,66 @@ +load "${REPOSITORY_ROOT}/test/helper/setup" +load "${REPOSITORY_ROOT}/test/helper/common" + +BATS_TEST_NAME_PREFIX='[OAuth2] ' +CONTAINER1_NAME='dms-test_oauth2' +CONTAINER2_NAME='dms-test_oauth2_provider' + +function setup_file() { + export DMS_TEST_NETWORK='test-network-oauth2' + export DMS_DOMAIN='example.test' + export FQDN_MAIL="mail.${DMS_DOMAIN}" + export FQDN_OAUTH2="oauth2.${DMS_DOMAIN}" + + # Link the test containers to separate network: + # NOTE: If the network already exists, test will fail to start. + docker network create "${DMS_TEST_NETWORK}" + + # Setup local oauth2 provider service: + docker run --rm -d --name "${CONTAINER2_NAME}" \ + --hostname "${FQDN_OAUTH2}" \ + --network "${DMS_TEST_NETWORK}" \ + --volume "${REPOSITORY_ROOT}/test/config/oauth2/:/app/" \ + docker.io/library/python:latest \ + python /app/provider.py + + _run_until_success_or_timeout 20 sh -c "docker logs ${CONTAINER2_NAME} 2>&1 | grep 'Starting server'" + + # + # Setup DMS container + # + + # Add OAUTH2 configuration so that Dovecot can reach out to our mock provider (CONTAINER2) + local ENV_OAUTH2_CONFIG=( + --env ENABLE_OAUTH2=1 + --env OAUTH2_INTROSPECTION_URL=http://oauth2.example.test/userinfo/ + ) + + export CONTAINER_NAME=${CONTAINER1_NAME} + local CUSTOM_SETUP_ARGUMENTS=( + "${ENV_OAUTH2_CONFIG[@]}" + + --hostname "${FQDN_MAIL}" + --network "${DMS_TEST_NETWORK}" + ) + + _init_with_defaults + _common_container_setup 'CUSTOM_SETUP_ARGUMENTS' + _wait_for_tcp_port_in_container 143 + + # Set default implicit container fallback for helpers: + export CONTAINER_NAME=${CONTAINER1_NAME} +} + +function teardown_file() { + docker rm -f "${CONTAINER1_NAME}" "${CONTAINER2_NAME}" + docker network rm "${DMS_TEST_NETWORK}" +} + + +@test "oauth2: imap connect and authentication works" { + # An initial connection needs to be made first, otherwise the auth attempt fails + _run_in_container_bash 'nc -vz 0.0.0.0 143' + + _nc_wrapper 'auth/imap-oauth2-auth.txt' '-w 1 0.0.0.0 143' + assert_output --partial 'Examine completed' +}