diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f32d55870f..f03fa6cc87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ on: permissions: contents: read + packages: read jobs: lint: @@ -231,9 +232,6 @@ jobs: --partition hash:${{ matrix.partition }}/8 test-ldap: - # binami ldap image is no longer served by ecr - # disable this job for now to avoid CI fails - if: false needs: build runs-on: @@ -255,6 +253,13 @@ jobs: - name: Download cargo-nextest binary run: curl -LsSf https://get.nexte.st/latest/linux | tar zxf - + - name: Log in to ghcr.io + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Start Postgres and OpenLDAP run: docker compose -p defguard-ldap -f docker-compose.ldap-test.yaml up -d --wait db openldap diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5333efe64b..56140dd538 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,9 @@ jobs: - X64 env: SQLX_OFFLINE: "1" + # Force the installed stable toolchain. RUSTUP_TOOLCHAIN takes precedence + # over any stale rustup directory override left on the self-hosted runner. + RUSTUP_TOOLCHAIN: "stable" # sccache SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" @@ -221,14 +224,15 @@ jobs: fpm_args: "defguard-${{ env.VERSION }}-x86_64-unknown-freebsd=/usr/local/bin/defguard freebsd/defguard=/usr/local/etc/rc.d/defguard - .env.example=/etc/defguard/core.conf" + .env.example=/etc/defguard/core.conf.sample" fpm_opts: "--architecture amd64 --output-type freebsd --version ${{ env.VERSION }} --package defguard-${{ env.VERSION }}_x86_64-unknown-freebsd.pkg --freebsd-osversion '*' - --depends openssl" + --depends openssl + --after-install freebsd/post-install.sh" - name: Upload Linux x86_64 archive uses: shogo82148/actions-upload-release-asset@394b3c11c3cfc038b5396ad265c074065cf875c3 # v1.10.2 diff --git a/.github/workflows/test-apt-repo.yml b/.github/workflows/test-apt-repo.yml index 1757369181..0d58dc7111 100644 --- a/.github/workflows/test-apt-repo.yml +++ b/.github/workflows/test-apt-repo.yml @@ -2,10 +2,11 @@ name: Test APT repository "on": schedule: - - cron: "0 6 * * *" + - cron: "0 */6 * * *" workflow_dispatch: - release: - types: [published] + workflow_run: + workflows: ["Update repositories with packages"] + types: [completed] jobs: test-apt-install: @@ -20,7 +21,7 @@ jobs: fail-fast: false matrix: package: [defguard, defguard-proxy, defguard-gateway] - component: [release, pre-release] + component: [release, pre-release, release-2.0, pre-release-2.0] include: - package: defguard github_repo: DefGuard/defguard @@ -47,35 +48,65 @@ jobs: - name: Update APT cache run: apt-get update -y + - name: Check package availability in component + run: | + CANDIDATE=$(apt-cache policy ${{ matrix.package }} | awk '/Candidate:/ {print $2}') + if [ -z "$CANDIDATE" ] || [ "$CANDIDATE" = "(none)" ]; then + echo "::notice::${{ matrix.package }} not available in component ${{ matrix.component }}, skipping" + echo "SKIP=true" >> $GITHUB_ENV + else + echo "Candidate version: $CANDIDATE" + echo "SKIP=false" >> $GITHUB_ENV + fi + - name: Get expected version from GitHub + if: env.SKIP != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - if [ "${{ matrix.component }}" = "release" ]; then - VERSION=$(curl -sf \ - -H "Authorization: Bearer $GH_TOKEN" \ - https://api.github.com/repos/${{ matrix.github_repo }}/releases/latest \ - | jq -r '.tag_name') - else - VERSION=$(curl -sf \ - -H "Authorization: Bearer $GH_TOKEN" \ - https://api.github.com/repos/${{ matrix.github_repo }}/releases \ - | jq -r '[.[] | select(.prerelease == true)][0].tag_name') + case "${{ matrix.component }}" in + release-2.0) PRERELEASE=false; MAJOR=v2. ;; + pre-release-2.0) PRERELEASE=true; MAJOR=v2. ;; + release) PRERELEASE=false; MAJOR=v1. ;; + pre-release) PRERELEASE=true; MAJOR=v1. ;; + esac + VERSION=$(curl -sf \ + -H "Authorization: Bearer $GH_TOKEN" \ + https://api.github.com/repos/${{ matrix.github_repo }}/releases \ + | jq -r --argjson pre "$PRERELEASE" --arg major "$MAJOR" \ + '[.[] | select(.prerelease == $pre and (.tag_name | startswith($major)))][0].tag_name // empty') + if [ -z "$VERSION" ]; then + echo "::notice::no $MAJOR release (prerelease=$PRERELEASE) of ${{ matrix.package }} on GitHub, skipping" + echo "SKIP=true" >> $GITHUB_ENV + exit 0 fi VERSION="${VERSION#v}" + + # legacy pre-release still holds 2.0 betas published before the + # component split; accept them instead of expecting the latest v1.x + CANDIDATE=$(apt-cache policy ${{ matrix.package }} | awk '/Candidate:/ {print $2}') + if [ "${{ matrix.component }}" = "pre-release" ] && [ "${CANDIDATE%%.*}" != "1" ]; then + echo "::notice::candidate $CANDIDATE is from before the component split, skipping version comparison" + VERSION="" + fi + echo "Expected version: $VERSION" echo "EXPECTED_VERSION=$VERSION" >> $GITHUB_ENV - name: Install ${{ matrix.package }} + if: env.SKIP != 'true' run: apt-get install -y ${{ matrix.package }} - name: Verify ${{ matrix.package }} version + if: env.SKIP != 'true' run: | INSTALLED=$(dpkg -s ${{ matrix.package }} | grep '^Version:' | awk '{print $2}') echo "Installed version: $INSTALLED" - echo "Expected version: $EXPECTED_VERSION" - if [ "$INSTALLED" != "$EXPECTED_VERSION" ]; then - echo "Version mismatch!" - exit 1 + if [ -n "$EXPECTED_VERSION" ]; then + echo "Expected version: $EXPECTED_VERSION" + if [ "$INSTALLED" != "$EXPECTED_VERSION" ]; then + echo "Version mismatch!" + exit 1 + fi fi ${{ matrix.package }} -V diff --git a/.github/workflows/update-repositories.yml b/.github/workflows/update-repositories.yml index 603b831751..7fa6af2e1c 100644 --- a/.github/workflows/update-repositories.yml +++ b/.github/workflows/update-repositories.yml @@ -96,3 +96,54 @@ jobs: done (aws s3 ls s3://apt.defguard.net/dists/ --recursive; aws s3 ls s3://apt.defguard.net/pool/ --recursive) | awk '{print ""$4"
"}' > index.html aws s3 cp index.html s3://apt.defguard.net/ --acl public-read + + verify-apt-repo: + needs: + - apt-sign + runs-on: + - self-hosted + - Linux + - X64 + steps: + - name: Verify published repository signatures and metadata + run: | + set -euo pipefail + sudo apt update -y + sudo apt install -y curl gpg + + WORKDIR=$(mktemp -d) + trap 'rm -rf "$WORKDIR"' EXIT + cd "$WORKDIR" + + curl -fsSL https://apt.defguard.net/defguard.asc | gpg --dearmor -o keyring.gpg + + for DIST in trixie bookworm; do + echo "=== Verifying $DIST ===" + curl -fsSL "https://apt.defguard.net/dists/${DIST}/Release" -o Release + curl -fsSL "https://apt.defguard.net/dists/${DIST}/Release.gpg" -o Release.gpg + curl -fsSL "https://apt.defguard.net/dists/${DIST}/InRelease" -o InRelease + + gpgv --keyring "$WORKDIR/keyring.gpg" Release.gpg Release + gpgv --keyring "$WORKDIR/keyring.gpg" InRelease + + for COMPONENT in $(awk '/^Components:/ {for (i=2; i<=NF; i++) print $i}' Release); do + PACKAGES_PATH="${COMPONENT}/binary-amd64/Packages" + EXPECTED_SHA=$(awk -v p="$PACKAGES_PATH" '/^SHA256:/{s=1; next} /^[A-Za-z]/{s=0} s && $3 == p {print $1; exit}' Release) + # InRelease must describe the same metadata as the detached-signed + # Release, otherwise apt clients see a signature/content mismatch + INRELEASE_SHA=$(awk -v p="$PACKAGES_PATH" '/^SHA256:/{s=1; next} /^[A-Za-z-]/{s=0} s && $3 == p {print $1; exit}' InRelease) + if [ -z "$EXPECTED_SHA" ] || [ "$EXPECTED_SHA" != "$INRELEASE_SHA" ]; then + echo "SHA256 entry for $PACKAGES_PATH missing or differs between Release and InRelease in $DIST" + exit 1 + fi + curl -fsSL "https://apt.defguard.net/dists/${DIST}/${PACKAGES_PATH}" -o Packages + ACTUAL_SHA=$(sha256sum Packages | awk '{print $1}') + if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then + echo "Checksum mismatch for $DIST/$PACKAGES_PATH" + echo "expected: $EXPECTED_SHA" + echo "actual: $ACTUAL_SHA" + exit 1 + fi + echo "$DIST/$PACKAGES_PATH OK" + done + done diff --git a/.sqlx/query-3c23334b506ec2a589eec2e988079867d85283a1a055f7551d278c93bd866d18.json b/.sqlx/query-3c23334b506ec2a589eec2e988079867d85283a1a055f7551d278c93bd866d18.json index 0407fe9521..cedbbb41ad 100644 --- a/.sqlx/query-3c23334b506ec2a589eec2e988079867d85283a1a055f7551d278c93bd866d18.json +++ b/.sqlx/query-3c23334b506ec2a589eec2e988079867d85283a1a055f7551d278c93bd866d18.json @@ -50,9 +50,7 @@ "client", "vpn", "enrollment", - "posture", - "active_directory", - "ldap" + "posture" ] } } @@ -82,7 +80,7 @@ "nullable": [ false, false, - true, + false, false, true, true, diff --git a/.sqlx/query-4bb5ca9ed7718e206afe39c6a2adc10ffe1db95cec3bb2d20123025f4af46b21.json b/.sqlx/query-4bb5ca9ed7718e206afe39c6a2adc10ffe1db95cec3bb2d20123025f4af46b21.json index c3c80f00ea..aea8c1773f 100644 --- a/.sqlx/query-4bb5ca9ed7718e206afe39c6a2adc10ffe1db95cec3bb2d20123025f4af46b21.json +++ b/.sqlx/query-4bb5ca9ed7718e206afe39c6a2adc10ffe1db95cec3bb2d20123025f4af46b21.json @@ -21,9 +21,7 @@ "client", "vpn", "enrollment", - "posture", - "active_directory", - "ldap" + "posture" ] } } diff --git a/.sqlx/query-f6110462d7fd58131e1a2d8cb52711ed9e490ab111a2c0cbb499587f351697b8.json b/.sqlx/query-4f0e9930112726e7d5a0d0078badda9b2de31992dd5f676e7835325557bb54ad.json similarity index 89% rename from .sqlx/query-f6110462d7fd58131e1a2d8cb52711ed9e490ab111a2c0cbb499587f351697b8.json rename to .sqlx/query-4f0e9930112726e7d5a0d0078badda9b2de31992dd5f676e7835325557bb54ad.json index 789a2f2782..12fb17fa81 100644 --- a/.sqlx/query-f6110462d7fd58131e1a2d8cb52711ed9e490ab111a2c0cbb499587f351697b8.json +++ b/.sqlx/query-4f0e9930112726e7d5a0d0078badda9b2de31992dd5f676e7835325557bb54ad.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"openidprovider\" SET \"name\" = $2,\"base_url\" = $3,\"kind\" = $4,\"client_id\" = $5,\"client_secret\" = $6,\"display_name\" = $7,\"google_service_account_key\" = $8,\"google_service_account_email\" = $9,\"admin_email\" = $10,\"directory_sync_enabled\" = $11,\"directory_sync_interval\" = $12,\"directory_sync_user_behavior\" = $13,\"directory_sync_admin_behavior\" = $14,\"directory_sync_target\" = $15,\"okta_private_jwk\" = $16,\"okta_dirsync_client_id\" = $17,\"directory_sync_group_match\" = $18,\"jumpcloud_api_key\" = $19,\"prefetch_users\" = $20 WHERE id = $1", + "query": "UPDATE \"openidprovider\" SET \"name\" = $2,\"base_url\" = $3,\"kind\" = $4,\"client_id\" = $5,\"client_secret\" = $6,\"display_name\" = $7,\"google_service_account_key\" = $8,\"google_service_account_email\" = $9,\"admin_email\" = $10,\"directory_sync_enabled\" = $11,\"directory_sync_interval\" = $12,\"directory_sync_user_behavior\" = $13,\"directory_sync_admin_behavior\" = $14,\"directory_sync_target\" = $15,\"okta_private_jwk\" = $16,\"okta_dirsync_client_id\" = $17,\"directory_sync_group_match\" = $18,\"jumpcloud_api_key\" = $19,\"prefetch_users\" = $20,\"directory_sync_user_groups\" = $21 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -71,10 +71,11 @@ "Text", "TextArray", "Text", - "Bool" + "Bool", + "TextArray" ] }, "nullable": [] }, - "hash": "f6110462d7fd58131e1a2d8cb52711ed9e490ab111a2c0cbb499587f351697b8" + "hash": "4f0e9930112726e7d5a0d0078badda9b2de31992dd5f676e7835325557bb54ad" } diff --git a/.sqlx/query-7642a85c7e4a299c2ae6b7deba3642ab7efcb9a7375d01ce85078389edf1340b.json b/.sqlx/query-5ccccf16000b68f9313d1b8c44f174b22a1bd1859f7593228f5eeac33da0ac7d.json similarity index 91% rename from .sqlx/query-7642a85c7e4a299c2ae6b7deba3642ab7efcb9a7375d01ce85078389edf1340b.json rename to .sqlx/query-5ccccf16000b68f9313d1b8c44f174b22a1bd1859f7593228f5eeac33da0ac7d.json index 075f8cf098..027141878d 100644 --- a/.sqlx/query-7642a85c7e4a299c2ae6b7deba3642ab7efcb9a7375d01ce85078389edf1340b.json +++ b/.sqlx/query-5ccccf16000b68f9313d1b8c44f174b22a1bd1859f7593228f5eeac33da0ac7d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"base_url\",\"kind\" \"kind: _\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\",\"jumpcloud_api_key\",\"prefetch_users\" FROM \"openidprovider\" WHERE id = $1", + "query": "SELECT id, \"name\",\"base_url\",\"kind\" \"kind: _\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\",\"jumpcloud_api_key\",\"prefetch_users\",\"directory_sync_user_groups\" \"directory_sync_user_groups?: _\" FROM \"openidprovider\" LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -149,10 +149,16 @@ "ordinal": 19, "name": "prefetch_users", "type_info": "Bool" + }, + { + "ordinal": 20, + "name": "directory_sync_user_groups?: _", + "type_info": "TextArray" } ], "parameters": { "Left": [ + "Int8", "Int8" ] }, @@ -176,8 +182,9 @@ true, false, true, - false + false, + true ] }, - "hash": "7642a85c7e4a299c2ae6b7deba3642ab7efcb9a7375d01ce85078389edf1340b" + "hash": "5ccccf16000b68f9313d1b8c44f174b22a1bd1859f7593228f5eeac33da0ac7d" } diff --git a/.sqlx/query-1332e9c8c375774902845e5bebddd49aadbd2516b1ff4fb962d1fc2437401064.json b/.sqlx/query-6958b9ccddab2fd32f18b8486f0df05d0ba8c8fbeed2bbe7a97d32e0841c065f.json similarity index 88% rename from .sqlx/query-1332e9c8c375774902845e5bebddd49aadbd2516b1ff4fb962d1fc2437401064.json rename to .sqlx/query-6958b9ccddab2fd32f18b8486f0df05d0ba8c8fbeed2bbe7a97d32e0841c065f.json index 0cd4df0c7e..2a8b664291 100644 --- a/.sqlx/query-1332e9c8c375774902845e5bebddd49aadbd2516b1ff4fb962d1fc2437401064.json +++ b/.sqlx/query-6958b9ccddab2fd32f18b8486f0df05d0ba8c8fbeed2bbe7a97d32e0841c065f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"openidprovider\" (\"name\",\"base_url\",\"kind\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\",\"directory_sync_admin_behavior\",\"directory_sync_target\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\",\"jumpcloud_api_key\",\"prefetch_users\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) RETURNING id", + "query": "INSERT INTO \"openidprovider\" (\"name\",\"base_url\",\"kind\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\",\"directory_sync_admin_behavior\",\"directory_sync_target\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\",\"jumpcloud_api_key\",\"prefetch_users\",\"directory_sync_user_groups\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) RETURNING id", "describe": { "columns": [ { @@ -76,12 +76,13 @@ "Text", "TextArray", "Text", - "Bool" + "Bool", + "TextArray" ] }, "nullable": [ false ] }, - "hash": "1332e9c8c375774902845e5bebddd49aadbd2516b1ff4fb962d1fc2437401064" + "hash": "6958b9ccddab2fd32f18b8486f0df05d0ba8c8fbeed2bbe7a97d32e0841c065f" } diff --git a/.sqlx/query-4c259991024808243d03443cb8725da87df96619428383083bded91c2dfdf598.json b/.sqlx/query-6b00d15a862df59524c798d0ab3adb3452ac3af78d97cfdd2a7fa1cb9a1cf4b3.json similarity index 92% rename from .sqlx/query-4c259991024808243d03443cb8725da87df96619428383083bded91c2dfdf598.json rename to .sqlx/query-6b00d15a862df59524c798d0ab3adb3452ac3af78d97cfdd2a7fa1cb9a1cf4b3.json index fb787d4617..333f522cfd 100644 --- a/.sqlx/query-4c259991024808243d03443cb8725da87df96619428383083bded91c2dfdf598.json +++ b/.sqlx/query-6b00d15a862df59524c798d0ab3adb3452ac3af78d97cfdd2a7fa1cb9a1cf4b3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, base_url, kind \"kind: OpenIdProviderKind\", client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled,\n directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key, prefetch_users FROM openidprovider WHERE name = $1", + "query": "SELECT id, name, base_url, kind \"kind: OpenIdProviderKind\", client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled,\n directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key, prefetch_users, directory_sync_user_groups FROM openidprovider WHERE name = $1", "describe": { "columns": [ { @@ -149,6 +149,11 @@ "ordinal": 19, "name": "prefetch_users", "type_info": "Bool" + }, + { + "ordinal": 20, + "name": "directory_sync_user_groups", + "type_info": "TextArray" } ], "parameters": { @@ -176,8 +181,9 @@ true, false, true, - false + false, + true ] }, - "hash": "4c259991024808243d03443cb8725da87df96619428383083bded91c2dfdf598" + "hash": "6b00d15a862df59524c798d0ab3adb3452ac3af78d97cfdd2a7fa1cb9a1cf4b3" } diff --git a/.sqlx/query-f851e3667522ee439c614f66e13d89773b783a1b738c453791c45747b11aa106.json b/.sqlx/query-6be3906124cfc53600bd13598f65029dc75dcda356c5e9f66a9d296a86c5202d.json similarity index 92% rename from .sqlx/query-f851e3667522ee439c614f66e13d89773b783a1b738c453791c45747b11aa106.json rename to .sqlx/query-6be3906124cfc53600bd13598f65029dc75dcda356c5e9f66a9d296a86c5202d.json index 088bb362dc..4e80bb58d7 100644 --- a/.sqlx/query-f851e3667522ee439c614f66e13d89773b783a1b738c453791c45747b11aa106.json +++ b/.sqlx/query-6be3906124cfc53600bd13598f65029dc75dcda356c5e9f66a9d296a86c5202d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"base_url\",\"kind\" \"kind: _\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\",\"jumpcloud_api_key\",\"prefetch_users\" FROM \"openidprovider\"", + "query": "SELECT id, \"name\",\"base_url\",\"kind\" \"kind: _\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\",\"jumpcloud_api_key\",\"prefetch_users\",\"directory_sync_user_groups\" \"directory_sync_user_groups?: _\" FROM \"openidprovider\"", "describe": { "columns": [ { @@ -149,6 +149,11 @@ "ordinal": 19, "name": "prefetch_users", "type_info": "Bool" + }, + { + "ordinal": 20, + "name": "directory_sync_user_groups?: _", + "type_info": "TextArray" } ], "parameters": { @@ -174,8 +179,9 @@ true, false, true, - false + false, + true ] }, - "hash": "f851e3667522ee439c614f66e13d89773b783a1b738c453791c45747b11aa106" + "hash": "6be3906124cfc53600bd13598f65029dc75dcda356c5e9f66a9d296a86c5202d" } diff --git a/.sqlx/query-51ee8e7b679a3016d766650b4660cb654e233b6482388e464b43cd12dce91a26.json b/.sqlx/query-6ebea8c1361717427340d67852bed6990333906e591f4d7730e6542e355f77f0.json similarity index 92% rename from .sqlx/query-51ee8e7b679a3016d766650b4660cb654e233b6482388e464b43cd12dce91a26.json rename to .sqlx/query-6ebea8c1361717427340d67852bed6990333906e591f4d7730e6542e355f77f0.json index f40be7142a..e3fbc70591 100644 --- a/.sqlx/query-51ee8e7b679a3016d766650b4660cb654e233b6482388e464b43cd12dce91a26.json +++ b/.sqlx/query-6ebea8c1361717427340d67852bed6990333906e591f4d7730e6542e355f77f0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"base_url\",\"kind\" \"kind: _\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\",\"jumpcloud_api_key\",\"prefetch_users\" FROM \"openidprovider\" LIMIT $1 OFFSET $2", + "query": "SELECT id, \"name\",\"base_url\",\"kind\" \"kind: _\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\",\"okta_private_jwk\",\"okta_dirsync_client_id\",\"directory_sync_group_match\" \"directory_sync_group_match: _\",\"jumpcloud_api_key\",\"prefetch_users\",\"directory_sync_user_groups\" \"directory_sync_user_groups?: _\" FROM \"openidprovider\" WHERE id = $1", "describe": { "columns": [ { @@ -149,11 +149,15 @@ "ordinal": 19, "name": "prefetch_users", "type_info": "Bool" + }, + { + "ordinal": 20, + "name": "directory_sync_user_groups?: _", + "type_info": "TextArray" } ], "parameters": { "Left": [ - "Int8", "Int8" ] }, @@ -177,8 +181,9 @@ true, false, true, - false + false, + true ] }, - "hash": "51ee8e7b679a3016d766650b4660cb654e233b6482388e464b43cd12dce91a26" + "hash": "6ebea8c1361717427340d67852bed6990333906e591f4d7730e6542e355f77f0" } diff --git a/.sqlx/query-80d1a83534373ea541755f4611b9ee92c9bcc201b656226611f33cfa8219390b.json b/.sqlx/query-80d1a83534373ea541755f4611b9ee92c9bcc201b656226611f33cfa8219390b.json index cb202f4fb4..61769c70ea 100644 --- a/.sqlx/query-80d1a83534373ea541755f4611b9ee92c9bcc201b656226611f33cfa8219390b.json +++ b/.sqlx/query-80d1a83534373ea541755f4611b9ee92c9bcc201b656226611f33cfa8219390b.json @@ -50,9 +50,7 @@ "client", "vpn", "enrollment", - "posture", - "active_directory", - "ldap" + "posture" ] } } @@ -83,7 +81,7 @@ "nullable": [ false, false, - true, + false, false, true, true, diff --git a/.sqlx/query-bbff781ddf881678e2d24bf292a2b1506bf33b00c4ef21c0761d6c3a4f6be964.json b/.sqlx/query-8e29414e1f03a0de0c6cb94d1a682424e0134730ebf81dad13a11e17d79242c8.json similarity index 93% rename from .sqlx/query-bbff781ddf881678e2d24bf292a2b1506bf33b00c4ef21c0761d6c3a4f6be964.json rename to .sqlx/query-8e29414e1f03a0de0c6cb94d1a682424e0134730ebf81dad13a11e17d79242c8.json index 351367d6ff..701db4da0e 100644 --- a/.sqlx/query-bbff781ddf881678e2d24bf292a2b1506bf33b00c4ef21c0761d6c3a4f6be964.json +++ b/.sqlx/query-8e29414e1f03a0de0c6cb94d1a682424e0134730ebf81dad13a11e17d79242c8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name, base_url, kind \"kind: OpenIdProviderKind\", client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key, prefetch_users FROM openidprovider LIMIT 1", + "query": "SELECT id, name, base_url, kind \"kind: OpenIdProviderKind\", client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\", okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key, prefetch_users, directory_sync_user_groups FROM openidprovider LIMIT 1", "describe": { "columns": [ { @@ -149,6 +149,11 @@ "ordinal": 19, "name": "prefetch_users", "type_info": "Bool" + }, + { + "ordinal": 20, + "name": "directory_sync_user_groups", + "type_info": "TextArray" } ], "parameters": { @@ -174,8 +179,9 @@ true, false, true, - false + false, + true ] }, - "hash": "bbff781ddf881678e2d24bf292a2b1506bf33b00c4ef21c0761d6c3a4f6be964" + "hash": "8e29414e1f03a0de0c6cb94d1a682424e0134730ebf81dad13a11e17d79242c8" } diff --git a/.sqlx/query-909ee355dfb5e56659b3d3b3ea5c7be8c83337a49ccc383c8b2e85e9862f0047.json b/.sqlx/query-909ee355dfb5e56659b3d3b3ea5c7be8c83337a49ccc383c8b2e85e9862f0047.json index b30bff8a2d..6280933018 100644 --- a/.sqlx/query-909ee355dfb5e56659b3d3b3ea5c7be8c83337a49ccc383c8b2e85e9862f0047.json +++ b/.sqlx/query-909ee355dfb5e56659b3d3b3ea5c7be8c83337a49ccc383c8b2e85e9862f0047.json @@ -26,9 +26,7 @@ "client", "vpn", "enrollment", - "posture", - "active_directory", - "ldap" + "posture" ] } } diff --git a/.sqlx/query-46fb95097c4cf79cdea7ef38c10cf8ca71f37bd60944f1b133a1ccd3a5fef98b.json b/.sqlx/query-93ac6465cd4f3100d8e3b6723d9fb4e6bb1502935468d1459e1693513c40821b.json similarity index 96% rename from .sqlx/query-46fb95097c4cf79cdea7ef38c10cf8ca71f37bd60944f1b133a1ccd3a5fef98b.json rename to .sqlx/query-93ac6465cd4f3100d8e3b6723d9fb4e6bb1502935468d1459e1693513c40821b.json index 0a6f59c7c3..76a93fcc47 100644 --- a/.sqlx/query-46fb95097c4cf79cdea7ef38c10cf8ca71f37bd60944f1b133a1ccd3a5fef98b.json +++ b/.sqlx/query-93ac6465cd4f3100d8e3b6723d9fb4e6bb1502935468d1459e1693513c40821b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, smtp_authentication = $15, smtp_oauth_issuer_url = $16, smtp_oauth_client_id = $17, smtp_oauth_client_secret = $18, smtp_oauth_refresh_token = $19, enrollment_vpn_step_optional = $20, enrollment_welcome_message = $21, enrollment_welcome_email = $22, enrollment_welcome_email_subject = $23, enrollment_use_welcome_message_as_email = $24, enrollment_send_welcome_email = $25, uuid = $26, ldap_url = $27, ldap_bind_username = $28, ldap_bind_password = $29, ldap_group_search_base = $30, ldap_user_search_base = $31, ldap_user_obj_class = $32, ldap_group_obj_class = $33, ldap_username_attr = $34, ldap_groupname_attr = $35, ldap_group_member_attr = $36, ldap_member_attr = $37, ldap_use_starttls = $38, ldap_tls_verify_cert = $39, openid_create_account = $40, license = $41, gateway_disconnect_notifications_enabled = $42, gateway_disconnect_notifications_inactivity_threshold = $43, gateway_disconnect_notifications_reconnect_notification_enabled = $44, ldap_sync_status = $45, ldap_enabled = $46, ldap_sync_enabled = $47, ldap_is_authoritative = $48, ldap_sync_interval = $49, ldap_user_auxiliary_obj_classes = $50, ldap_uses_ad = $51, ldap_user_rdn_attr = $52, ldap_sync_groups = $53, ldap_remote_enrollment_enabled = $54, ldap_remote_enrollment_send_invite = $55, openid_username_handling = $56, defguard_url = $57, default_admin_group_name = $58, authentication_period_days = $59, mfa_code_timeout_seconds = $60, public_proxy_url = $61, default_admin_id = $62, secret_key = $63, openid_signing_key_der = $64, enable_stats_purge = $65, stats_purge_frequency_hours = $66, stats_purge_threshold_days = $67, enrollment_token_timeout_hours = $68, password_reset_token_timeout_hours = $69, enrollment_session_timeout_minutes = $70, password_reset_session_timeout_minutes = $71, ldap_sync_account_status = $72 WHERE id = 1", + "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, smtp_authentication = $15, smtp_oauth_issuer_url = $16, smtp_oauth_client_id = $17, smtp_oauth_client_secret = $18, smtp_oauth_refresh_token = $19, enrollment_vpn_step_optional = $20, enrollment_welcome_message = $21, enrollment_welcome_email = $22, enrollment_welcome_email_subject = $23, enrollment_use_welcome_message_as_email = $24, enrollment_send_welcome_email = $25, uuid = $26, ldap_url = $27, ldap_bind_username = $28, ldap_bind_password = $29, ldap_group_search_base = $30, ldap_user_search_base = $31, ldap_user_obj_class = $32, ldap_group_obj_class = $33, ldap_username_attr = $34, ldap_groupname_attr = $35, ldap_group_member_attr = $36, ldap_member_attr = $37, ldap_use_starttls = $38, ldap_tls_verify_cert = $39, openid_create_account = $40, license = $41, gateway_disconnect_notifications_enabled = $42, gateway_disconnect_notifications_inactivity_threshold = $43, gateway_disconnect_notifications_reconnect_notification_enabled = $44, ldap_sync_status = $45, ldap_enabled = $46, ldap_sync_enabled = $47, ldap_is_authoritative = $48, ldap_sync_interval = $49, ldap_user_auxiliary_obj_classes = $50, ldap_uses_ad = $51, ldap_user_rdn_attr = $52, ldap_sync_groups = $53, ldap_remote_enrollment_enabled = $54, ldap_remote_enrollment_send_invite = $55, openid_username_handling = $56, defguard_url = $57, default_admin_group_name = $58, authentication_period_days = $59, mfa_code_timeout_seconds = $60, public_proxy_url = $61, default_admin_id = $62, secret_key = $63, openid_signing_key_der = $64, enable_stats_purge = $65, stats_purge_frequency_hours = $66, stats_purge_threshold_days = $67, enrollment_token_timeout_hours = $68, password_reset_token_timeout_hours = $69, enrollment_session_timeout_minutes = $70, password_reset_session_timeout_minutes = $71, ldap_sync_account_status = $72, smtp_oauth_tenant_id = $73 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -119,10 +119,11 @@ "Int4", "Int4", "Int4", - "Bool" + "Bool", + "Text" ] }, "nullable": [] }, - "hash": "46fb95097c4cf79cdea7ef38c10cf8ca71f37bd60944f1b133a1ccd3a5fef98b" + "hash": "93ac6465cd4f3100d8e3b6723d9fb4e6bb1502935468d1459e1693513c40821b" } diff --git a/.sqlx/query-f0c2834f47d7c47dd84e4894b90689a81a3702a04f047db0386a2685ae6d212b.json b/.sqlx/query-f0c2834f47d7c47dd84e4894b90689a81a3702a04f047db0386a2685ae6d212b.json index eb291953c0..f7a2190295 100644 --- a/.sqlx/query-f0c2834f47d7c47dd84e4894b90689a81a3702a04f047db0386a2685ae6d212b.json +++ b/.sqlx/query-f0c2834f47d7c47dd84e4894b90689a81a3702a04f047db0386a2685ae6d212b.json @@ -50,9 +50,7 @@ "client", "vpn", "enrollment", - "posture", - "active_directory", - "ldap" + "posture" ] } } @@ -80,7 +78,7 @@ "nullable": [ false, false, - true, + false, false, true, true, diff --git a/.sqlx/query-20cd8429969e36695d67c52d7c27e824bbcf5ba0c8198c55bc610186f6dc3779.json b/.sqlx/query-fedbfaf857dd00fe4755286daf310dc7cb357e3f31cb48ae3119af8505a5f99f.json similarity index 93% rename from .sqlx/query-20cd8429969e36695d67c52d7c27e824bbcf5ba0c8198c55bc610186f6dc3779.json rename to .sqlx/query-fedbfaf857dd00fe4755286daf310dc7cb357e3f31cb48ae3119af8505a5f99f.json index 9232c0709f..3f3feeb616 100644 --- a/.sqlx/query-20cd8429969e36695d67c52d7c27e824bbcf5ba0c8198c55bc610186f6dc3779.json +++ b/.sqlx/query-fedbfaf857dd00fe4755286daf310dc7cb357e3f31cb48ae3119af8505a5f99f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE openidprovider SET name = $1, base_url = $2, kind = $3, client_id = $4, client_secret = $5, display_name = $6, google_service_account_key = $7, google_service_account_email = $8, admin_email = $9, directory_sync_enabled = $10, directory_sync_interval = $11, directory_sync_user_behavior = $12, directory_sync_admin_behavior = $13, directory_sync_target = $14, okta_private_jwk = $15, okta_dirsync_client_id = $16, directory_sync_group_match = $17, jumpcloud_api_key = $18, prefetch_users = $19 WHERE id = $20", + "query": "UPDATE openidprovider SET name = $1, base_url = $2, kind = $3, client_id = $4, client_secret = $5, display_name = $6, google_service_account_key = $7, google_service_account_email = $8, admin_email = $9, directory_sync_enabled = $10, directory_sync_interval = $11, directory_sync_user_behavior = $12, directory_sync_admin_behavior = $13, directory_sync_target = $14, okta_private_jwk = $15, okta_dirsync_client_id = $16, directory_sync_group_match = $17, jumpcloud_api_key = $18, prefetch_users = $19, directory_sync_user_groups = $20 WHERE id = $21", "describe": { "columns": [], "parameters": { @@ -71,10 +71,11 @@ "TextArray", "Text", "Bool", + "TextArray", "Int8" ] }, "nullable": [] }, - "hash": "20cd8429969e36695d67c52d7c27e824bbcf5ba0c8198c55bc610186f6dc3779" + "hash": "fedbfaf857dd00fe4755286daf310dc7cb357e3f31cb48ae3119af8505a5f99f" } diff --git a/Cargo.lock b/Cargo.lock index 922d6b2c84..e9efe6b9cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1387,7 +1387,7 @@ dependencies = [ [[package]] name = "defguard_common" -version = "2.0.1" +version = "2.1.0" dependencies = [ "anyhow", "argon2", @@ -1442,10 +1442,10 @@ dependencies = [ "bytes", "chrono", "claims", + "css-inline", "defguard_certs", "defguard_common", "defguard_grpc_tls", - "defguard_mail", "defguard_proto", "defguard_static_ip", "defguard_version", @@ -1453,18 +1453,23 @@ dependencies = [ "futures", "humantime", "hyper-util", + "image", "ipnetwork", "jsonwebkey", "jsonwebtoken", "ldap3", + "lettre", "matches", "md4", "model_derive", + "mrml", "openidconnect", "parse_link_header", "paste", "pgp", "prost", + "pulldown-cmark", + "qrforge", "rand 0.8.6", "regex", "reqwest", @@ -1588,31 +1593,6 @@ dependencies = [ "x509-parser 0.18.1", ] -[[package]] -name = "defguard_mail" -version = "0.0.0" -dependencies = [ - "chrono", - "claims", - "css-inline", - "defguard_common", - "humantime", - "image", - "lettre", - "mrml", - "openidconnect", - "pulldown-cmark", - "qrforge", - "reqwest", - "serde", - "serde_json", - "sqlx", - "tera", - "thiserror 2.0.18", - "tokio", - "tracing", -] - [[package]] name = "defguard_proto" version = "0.0.0" @@ -1639,7 +1619,6 @@ dependencies = [ "defguard_common", "defguard_core", "defguard_grpc_tls", - "defguard_mail", "defguard_proto", "defguard_version", "hyper-rustls", diff --git a/Cargo.toml b/Cargo.toml index dbd43ccae3..1fea50c014 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,12 +19,11 @@ redundant_closure = "warn" # internal crates defguard_setup = { path = "./crates/defguard_setup", version = "0.0.0" } -defguard_common = { path = "./crates/defguard_common", version = "2.0.1" } +defguard_common = { path = "./crates/defguard_common", version = "2.1.0" } defguard_static_ip = { path = "./crates/defguard_static_ip", version = "0.0.0" } defguard_core = { path = "./crates/defguard_core", version = "0.0.0" } defguard_event_logger = { path = "./crates/defguard_event_logger", version = "0.0.0" } defguard_gateway_manager = { path = "./crates/defguard_gateway_manager", version = "0.0.0" } -defguard_mail = { path = "./crates/defguard_mail", version = "0.0.0" } defguard_proto = { path = "./crates/defguard_proto", version = "0.0.0" } defguard_proxy_manager = { path = "./crates/defguard_proxy_manager", version = "0.0.0" } defguard_session_manager = { path = "./crates/defguard_session_manager", version = "0.0.0" } diff --git a/README.md b/README.md index 2816b702b3..6ae3c448c3 100644 --- a/README.md +++ b/README.md @@ -1,220 +1,87 @@ -

- defguard -

- -
-

- Defguard is an enterprise-grade open-source VPN solution built with the highest security standards in mind. It provides the world’s only multi-factor authentication (MFA) for WireGuard VPN connections, using either its built-in SSO (with TOTP, biometrics, etc.) or external SSO providers such as Google, Microsoft, Active Directory/LDAP, Okta, JumpCloud or any other OpenID Connect Provider. -

- -[Website](https://defguard.net) | [Getting Started](https://docs.defguard.net/#what-is-defguard) | [Features](https://github.com/defguard/defguard#features) | [Roadmap](https://github.com/orgs/defguard/projects/5) | [Support ❤](https://github.com/defguard/defguard#support) -
- -### Open, transparent, verifiable and inspectable - -- Our security approach: https://defguard.net/security/ -- Our public penetration tests reports: https://defguard.net/pentesting/ -- Daily SBOM CVE scan: https://defguard.net/sbom/ -- Our detailed roadmap: https://github.com/orgs/DefGuard/projects/5 -- Our Architecture Decision Records: https://app.gitbook.com/o/Z3mGSAbEj9iLdZ7cNFlL/s/kHPDOBrb5X1TB8O3GsjW/~/changes/86/in-depth/architecture-decision-records - -### Defguard provides Comprehensive Access Control (a complete security platform): - -- **[WireGuard® VPN with 2FA/MFA](https://docs.defguard.net/in-depth/architecture/architecture)** - not 2FA to "access application" like most solutions - - The only solution with [automatic and real-time synchronization](https://docs.defguard.net/features/remote-user-enrollment/automatic-real-time-desktop-client-configuration) for users' desktop client settings (including all VPNs/locations). - - Control users [ability to manage devices and VPN options](https://docs.defguard.net/features/wireguard/behavior-customization) -- [ACLs/Firewall Management](https://docs.defguard.net/features/access-control-list) for Linux and FreeBSD/OPNSense -- [Integrated SSO based on OpenID Connect](https://docs.defguard.net/features/openid-connect): - - significant cost saving, simplifying deployment and maintenance - - enabling features unavailable to VPN platforms relying upon 3rd party SSO integration -- Already using Google/Microsoft or other OpenID Provider? - [external OpenID provider support](https://docs.defguard.net/features/external-openid-providers) -- [Two way Active Directory/LDAP synchronization](https://docs.defguard.net/features/ldap-and-active-directory-integration/two-way-ldap-and-active-directory-synchronization) -- Only solution with [secure remote user Enrollment & Onboarding](https://docs.defguard.net/using-defguard-for-end-users/enrollment) -- Yubico YubiKey Hardware [security key management and provisioning](https://docs.defguard.net/features/yubikey-provisioning) -- Secure and robust architecture, featuring components and micro-services seamlessly deployable in diverse network setups (eg. utilizing network segments like Demilitarized Zones, Intranet with no external access, etc), ensuring a secure environment. -- Enterprise ready (multiple Locations/Gateways/Kubernetes deployment, etc..) -- Built on WireGuard® protocol which is faster than IPSec, and significantly faster than OpenVPN -- Built with Rust for speed and security - -See: -- [full list of features](https://github.com/defguard/defguard#features) -- [enterprise only features](https://docs.defguard.net/enterprise/enterprise-features) - -### Defguard makes it easy to manage complex VPN networks in a secure way +

+ defguard +

-locations-connections +Defguard is a self-hosted secure remote access platform that combines WireGuard VPN, identity and access management, multi-factor authentication, and network access control in a single solution. -#### Video introduction +Built with a security-first architecture, Defguard helps organizations securely manage access to infrastructure, applications, and private networks while maintaining full control over their environment. -Bear in in mind we are no youtubers - just engineers - here is a video introduction to defguard: +## Why Defguard? -
-

- -[![Introduction to defguard](https://img.youtube.com/vi/4PF7edMGBwk/hqdefault.jpg)](https://www.youtube.com/watch?v=4PF7edMGBwk) +Modern organizations often rely on multiple disconnected tools to manage identity, VPN access, authentication, and network permissions. Defguard brings these capabilities together into a unified platform designed for security, transparency, and operational simplicity. -

-
+Key principles behind Defguard: -### Control plane management (this video is few versions behind... - a lot has changed!) +- 📖 Open-source core (AGPL), open-code Enterprise components +- 🏠 Fully self-hosted — no external dependencies or data leaving your infrastructure +- 🔒 Security-first: [Zero-Trust VPN](https://docs.defguard.net/features/wireguard) with connection-level MFA, [architecture](https://docs.defguard.net/in-depth/architecture) designed to minimize attack surface +- 🔍 Transparency: [published SBOMs](https://defguard.net/sbom/), [penetration test reports](https://defguard.net/pentesting/), [architecture decision records](https://docs.defguard.net/in-depth/architecture-decision-records) -![](https://defguard.net/images/product/core/hero-image.png) +For detailed security information see the [secure-by-design documentation](https://docs.defguard.net/in-depth/secure-by-design). -![](https://github.com/DefGuard/docs/blob/docs/screencasts/defguard.gif?raw=true) +## Core Capabilities -Better quality video can [be viewed here](https://github.com/DefGuard/docs/raw/docs/screencasts/defguard-screencast.mkv) +- 🌐 **WireGuard VPN** — multiple locations with per-location access control, MFA per connection, self-service device setup, kernel and userspace support +- 👥 **Identity & Access Management** — internal OIDC provider for SSO, external OIDC (Google, Microsoft, custom), LDAP/AD sync, remote enrollment, user self-service +- 🔑 **Multi-Factor Authentication** — TOTP, WebAuthn/FIDO2, email tokens, biometric via mobile app +- 🛡️ **Firewall** — allow/deny rules per VPN location by user or group, applied in real time +- 📋 **Activity Log** — audit log with filtering and search; real-time SIEM streaming (Enterprise) +- 🔗 **Integrations** — webhooks and REST API -### Desktop Client with 2FA / MFA (Multi-Factor Authentication) +## Clients -#### Light +- 🖥️ **Desktop** (Linux, macOS, Windows) — VPN management with MFA, multi-instance and multi-location support, and real-time connection statistics. [Download](https://defguard.net/download/) +- 📱 **Mobile** (Android, iOS) — VPN management with MFA, QR code onboarding. [Android](https://play.google.com/store/apps/details?id=net.defguard.mobile) · [iOS](https://apps.apple.com/us/app/defguard-vpn-client/id6748068630) -![defguard desktop client](https://defguard.net/images/product/client/main-screen.png) +## Architecture -#### Dark +Defguard follows a component-based architecture designed to reduce attack surface and support secure deployments. -![defguard WireGuard MFA](https://github.com/DefGuard/docs/blob/docs/releases/0.9/mfa.png?raw=true) +

+ architecture +

-[Desktop client](https://github.com/DefGuard/client): +Strict division of responsibilities and network segmentation: +- **Core** — central management plane: identity, authentication, authorization, and policy +- **Edge** — public-facing entry point, exposes selected Defguard services [GitHub repo](https://github.com/DefGuard/proxy) +- **Gateway** — enforces network access policies for protected resources [GitHub repo](https://github.com/DefGuard/gateway) -- **2FA / Multi-Factor Authentication** with TOTP or email based tokens & WireGuard PSK -- [automatic and real-time synchronization](https://docs.defguard.net/features/remote-user-enrollment/automatic-real-time-desktop-client-configuration) for users' desktop client settings (including all VPNs/locations). -- Control users [ability to manage devices and VPN options](https://docs.defguard.net/features/wireguard/behavior-customization) -- Defguard instances as well as **any WireGuard tunnel** - just import your tunnels - one client for all WireGuard connections -- Secure and remote user enrollment - setting up password, automatically configuring the client for all VPN Locations/Networks -- Onboarding - displaying custom onboarding messages, with templates, links ... -- Ability to route predefined VPN traffic or all traffic (server needs to have NAT configured - in gateway example) -- Live & real-time network charts -- live VPN logs -- light/dark theme +For details refer to the [architecture documentation](https://docs.defguard.net/in-depth/architecture). -## Quick start +## Quick Start -The easiest way to run your own defguard instance is to use Docker and our [one-line install script](https://docs.defguard.net/getting-started/one-line-install). -Just run the command below in your shell and follow the prompts: +The fastest way to evaluate Defguard is with the [one-line installer](https://docs.defguard.net/getting-started/one-line-install): ```bash -curl --proto '=https' --tlsv1.2 -sSf -L https://raw.githubusercontent.com/DefGuard/deployment/main/docker-compose/setup.sh -O && bash setup.sh +bash <(curl -sSL https://raw.githubusercontent.com/defguard/deployment/main/docker-compose2.0/setup.sh) ``` -Here is a step-by-step video about this process: - -
-

- -[![Quickly deploy defguard](https://img.youtube.com/vi/MqlE6ZTn0bg/hqdefault.jpg)](https://www.youtube.com/watch?v=MqlE6ZTn0bg) - -

-
- -To learn more about the script and available options please see the [documentation](https://docs.defguard.net/getting-started/one-line-install). - -### Setup a VPN server in under 5 minutes !? - -Just follow [this tutorial](http://bit.ly/defguard-setup) - -## Manual deployment examples - -- [Standalone system package based install](https://docs.defguard.net/deployment-strategies/standalone-package-based-installation) -- Using [Docker Compose](https://docs.defguard.net/deployment-strategies/docker-compose) -- Using [Kubernetes](https://docs.defguard.net/deployment-strategies/kubernetes) - -## Roadmap & Development backlog - -[A detailed product roadmap and development status can be found here](https://github.com/orgs/DefGuard/projects/5/views/1) - -### ⛑️ Want to help? ⛑️ - -Here is a [dedicated view for **good first bugs**](https://github.com/orgs/DefGuard/projects/5/views/5) - -## Features - -* Remote Access: [WireGuard® VPN](https://www.wireguard.com/) server with: - - [Multi-Factor Authentication](https://docs.defguard.net/features/wireguard/multi-factor-authentication-mfa-2fa) with TOTP/Email & Pre-Shared Session Keys - - multiple VPN Locations (networks/sites) - with defined access (all users or only Admin group) - - multiple [Gateways](https://github.com/DefGuard/gateway) for each VPN Location (**high availability/failover**) - supported on a cluster of routers/firewalls for Linux, FreeBSD/PFSense/OPNSense - - **import your current WireGuard® server configuration (with a wizard!)** - - **most beautiful [Desktop Client!](https://github.com/defguard/client)** (in our opinion ;-)) - - automatic IP allocation - - [automatic and real-time synchronization](https://docs.defguard.net/features/remote-user-enrollment/automatic-real-time-desktop-client-configuration) for users' desktop client settings (including all VPNs/locations). - - control users [ability to manage devices and VPN options](https://docs.defguard.net/features/wireguard/behavior-customization) - - kernel (Linux, FreeBSD/OPNSense/PFSense) & userspace WireGuard® support with [our Rust library](https://github.com/defguard/wireguard-rs) - - dashboard and statistics overview of connected users/devices for admins - - *defguard is not an official WireGuard® project, and WireGuard is a registered trademark of Jason A. Donenfeld.* -* Identity & Account Management: - - SSO based on OpenID Connect](https://openid.net/developers/how-connect-works/) - - External SSO: [external OpenID provider support](https://docs.defguard.net/features/external-openid-providers) - - [Multi-Factor/2FA](https://en.wikipedia.org/wiki/Multi-factor_authentication) Authentication: - - [Time-based One-Time Password Algorithm](https://en.wikipedia.org/wiki/Time-based_one-time_password) (TOTP - e.g. Google Authenticator) - - WebAuthn / FIDO2 - for hardware key authentication support (eg. YubiKey, FaceID, TouchID, ...) - - Email based TOTP - - LDAP (tested on [OpenLDAP](https://www.openldap.org/)) synchronization - - [forward auth](https://docs.defguard.net/features/forward-auth) for reverse proxies (tested with Traefik and Caddy) - - nice UI to manage users - - Users **self-service** (besides typical data management, users can revoke access to granted apps, MFA, WireGuard®, etc.) -* Account Lifecycle Management: - - Secure remote (over the Internet) [user enrollment](https://docs.defguard.net/features/remote-user-enrollment) - on public web / Desktop Client - - User [onboarding after enrollment](https://docs.defguard.net/features/remote-user-enrollment/user-onboarding-after-enrollment) -* SSH & GPG public key management in user profile - with [SSH keys authentication for servers](https://docs.defguard.net/features/ssh-authentication) -* [Yubikey hardware keys](https://www.yubico.com/) provisioning for users by *one click* -* [Email/SMTP support](https://docs.defguard.net/features/notifications/setting-up-smtp-for-email-notifications) for notifications, remote enrollment and onboarding -* Easy support with [sending debug/support information](https://docs.defguard.net/support-1/troubleshooting/sending-support-info) -* Webhooks & REST API -* Built with [Rust](https://www.rust-lang.org/) for portability, security, and speed -* [UI Library](https://github.com/defguard/ui) - our beautiful React/TypeScript UI is a collection of React components: - - a set of custom and beautiful components for the layout - - Responsive Web Design (supporting mobile phones, tablets, etc..) - - [iOS Web App](https://www.macrumors.com/how-to/use-web-apps-iphone-ipad/) -* **Checked by professional security researchers** (see [comprehensive security report](https://defguard.net/pdf/isec-defguard.pdf)) -* End2End tests +⚠️ Warning! This installation method is intended for testing, demonstrations, and evaluation purposes only. It is not recommended for production deployments. See the [deployment documentation](https://docs.defguard.net/deployment-strategies/overview) for production deployment guidance, architecture recommendations, and high-availability configurations. ## Documentation -See the [documentation](https://docs.defguard.net/) for more information. +Comprehensive documentation is available at: https://docs.defguard.net -## Community and Support +## Video guides -Find us on Matrix: [#defguard:teonite.com](https://matrix.to/#/#defguard:teonite.com) +Visit out YouTube channel to see our [video guides](https://www.youtube.com/playlist?list=PLVR33X0CUHUcoyLshs9S8VbsGgggouCAW). -## License +## Community -The code in this repository is available under a dual licensing model: +We want to get as much feedback as possible, so we encourage you to: -1. Open Source License: The code, except for the contents of the "crates/defguard_core/src/enterprise" directory, is licensed under the AGPL license (see file LICENSE.md in this repository). This applies to the open core components of the software. -2. Enterprise License: All code in this repository (including within the "crates/defguard_core/src/enterprise" directory) is licensed under a separate Enterprise License (see file crates/defguard_core/src/enterprise/LICENSE.md). +- 💬 open a [GitHub discussion](https://github.com/DefGuard/defguard/discussions/new/choose) +- 🪲 report any missing [features](https://github.com/DefGuard/defguard/issues/new?assignees=&labels=feature&projects=&template=feature_request.md&title=) or [bugs](https://github.com/DefGuard/defguard/issues/new?assignees=&labels=bug&projects=&template=bug_report.md&title=) as issues ## Contributions Please review the [Contributing guide](https://docs.defguard.net/for-developers/contributing) for information on how to get started contributing to the project. You might also find our [environment setup guide](https://docs.defguard.net/for-developers/dev-env-setup) handy. -## Verifiability of releases - -We provide following ways to verify the authenticity and integrity of official releases: - -### Docker Image Verification with Cosign - -All official Docker images are signed using [Cosign](https://docs.sigstore.dev/cosign/overview/). To verify a Docker image: - -1. [Install](https://github.com/sigstore/cosign?tab=readme-ov-file#installation) cosign CLI - -2. Verify the image signature (replace with the tag you want to verify): - ```bash - cosign verify --certificate-identity-regexp="https://github.com/DefGuard/defguard" \ - --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ - ghcr.io/defguard/defguard: - ``` - -### Release Asset Verification - -All release assets (binaries, packages, etc.) include SHA256 checksums that are automatically generated and published with each GitHub release: - -1. Download the release asset and copy its corresponding checksum from the [releases page](https://github.com/DefGuard/defguard/releases) - -2. Verify the checksum: - ```bash - # Linux/macOS - echo known_sha256_checksum_of_the_file path/to/file | sha256sum --check - ``` +## License +The code in this repository is available under a dual licensing model: -# Legal +- Open Source License: The code, except for the contents of the "crates/defguard_core/src/enterprise" directory, is licensed under the AGPL license (see file LICENSE.md in this repository). This applies to the open core components of the software. +- Enterprise License: All code in this repository (including within the "crates/defguard_core/src/enterprise" directory) is licensed under a separate Enterprise License (see file crates/defguard_core/src/enterprise/LICENSE.md). -WireGuard® is [registered trademarks](https://www.wireguard.com/trademark-policy/) of Jason A. Donenfeld. +## Legal +WireGuard® is [registered trademarks](https://www.wireguard.com/trademark-policy/) of Jason A. Donenfeld. \ No newline at end of file diff --git a/crates/defguard_common/Cargo.toml b/crates/defguard_common/Cargo.toml index ad15490168..b8c30d0577 100644 --- a/crates/defguard_common/Cargo.toml +++ b/crates/defguard_common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "defguard_common" -version = "2.0.1" +version = "2.1.0" edition.workspace = true license-file.workspace = true homepage.workspace = true diff --git a/crates/defguard_common/src/db/models/settings/mod.rs b/crates/defguard_common/src/db/models/settings/mod.rs index e42df9cbe1..6c5e303c42 100644 --- a/crates/defguard_common/src/db/models/settings/mod.rs +++ b/crates/defguard_common/src/db/models/settings/mod.rs @@ -481,7 +481,7 @@ impl Settings { challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, \ smtp_port, smtp_encryption, smtp_user, smtp_password, smtp_sender, \ smtp_authentication, smtp_oauth_issuer_url, smtp_oauth_client_id, \ - smtp_oauth_client_secret, smtp_oauth_refresh_token, \ + smtp_oauth_client_secret, smtp_oauth_refresh_token, smtp_oauth_tenant_id, \ enrollment_vpn_step_optional, enrollment_welcome_message, \ enrollment_welcome_email, enrollment_welcome_email_subject, \ enrollment_use_welcome_message_as_email, enrollment_send_welcome_email, \ @@ -627,7 +627,8 @@ impl Settings { password_reset_token_timeout_hours = $69, \ enrollment_session_timeout_minutes = $70, \ password_reset_session_timeout_minutes = $71, \ - ldap_sync_account_status = $72 \ + ldap_sync_account_status = $72, \ + smtp_oauth_tenant_id = $73 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -701,6 +702,7 @@ impl Settings { self.enrollment_session_timeout_minutes, self.password_reset_session_timeout_minutes, self.ldap_sync_account_status, + self.smtp.oauth_tenant_id, ) .execute(executor) .await?; diff --git a/crates/defguard_common/src/db/models/settings/smtp.rs b/crates/defguard_common/src/db/models/settings/smtp.rs index c8e5982acb..6223400017 100644 --- a/crates/defguard_common/src/db/models/settings/smtp.rs +++ b/crates/defguard_common/src/db/models/settings/smtp.rs @@ -82,6 +82,10 @@ pub struct SmtpSettings { #[sqlx(rename = "smtp_oauth_refresh_token")] #[patch(attribute(serde(rename = "smtp_oauth_refresh_token")))] pub oauth_refresh_token: Option, + #[serde(rename = "smtp_oauth_tenant_id")] + #[sqlx(rename = "smtp_oauth_tenant_id")] + #[patch(attribute(serde(rename = "smtp_oauth_tenant_id")))] + pub oauth_tenant_id: Option, } impl SmtpSettings { @@ -105,6 +109,39 @@ impl SmtpSettings { Ok(()) } + + /// Check if all required options are properly configured. + /// This is meant to be used to check if sending emails is enabled in current instance. + #[must_use] + pub fn is_configured(&self) -> bool { + let string_not_empty = |string: &String| !string.is_empty(); + let secret_not_empty = |secret: &SecretStringWrapper| !secret.expose_secret().is_empty(); + + self.port.is_some() + && self.server.as_ref().is_some_and(string_not_empty) + && self.sender.as_ref().is_some_and(string_not_empty) + && match self.authentication { + SmtpAuthentication::None => true, + SmtpAuthentication::Login => { + self.user.as_ref().is_some_and(string_not_empty) + && self.password.as_ref().is_some_and(secret_not_empty) + } + SmtpAuthentication::XOAuth2 => { + self.oauth_issuer_url.as_ref().is_some_and(string_not_empty) + && self.oauth_client_id.as_ref().is_some_and(string_not_empty) + && self + .oauth_client_secret + .as_ref() + .is_some_and(secret_not_empty) + } + } + } + + /// Returns `true` is SMTP authentication is using XOAUTH2. + #[must_use] + pub fn is_xoauth2(&self) -> bool { + matches!(self.authentication, SmtpAuthentication::XOAuth2) + } } // Implement manually to avoid exposing secrets. @@ -119,6 +156,7 @@ impl fmt::Debug for SmtpSettings { .field("authentication", &self.authentication) .field("oauth_issuer_url", &self.oauth_issuer_url) .field("oauth_client_id", &self.oauth_client_id) + .field("oauth_tenant_id", &self.oauth_tenant_id) .finish_non_exhaustive() } } diff --git a/crates/defguard_core/Cargo.toml b/crates/defguard_core/Cargo.toml index 71005e69bb..8512c6d24d 100644 --- a/crates/defguard_core/Cargo.toml +++ b/crates/defguard_core/Cargo.toml @@ -10,7 +10,6 @@ rust-version.workspace = true [dependencies] # internal crates defguard_common = { workspace = true } -defguard_mail = { workspace = true } defguard_proto = { workspace = true } defguard_web_ui = { workspace = true } defguard_version = { workspace = true } @@ -19,6 +18,14 @@ defguard_certs = { workspace = true } defguard_grpc_tls = { workspace = true } defguard_static_ip = { workspace = true } +# mail (moved in-crate from the former defguard_mail crate) +lettre.workspace = true +pulldown-cmark.workspace = true +css-inline = "0.20" +image = "0.25" # match with qrforge +mrml = "6.0" +qrforge = { version = "0.1", default-features = false, features = ["image"] } + # external dependencies anyhow = { workspace = true } axum = { workspace = true } diff --git a/crates/defguard_core/src/db/models/activity_log/metadata.rs b/crates/defguard_core/src/db/models/activity_log/metadata.rs index 1c6c5fb155..08524f0bdf 100644 --- a/crates/defguard_core/src/db/models/activity_log/metadata.rs +++ b/crates/defguard_core/src/db/models/activity_log/metadata.rs @@ -316,6 +316,7 @@ pub struct OpenIdProviderNoSecrets { pub directory_sync_target: DirectorySyncTarget, pub okta_dirsync_client_id: Option, pub directory_sync_group_match: Vec, + pub directory_sync_user_groups: Option>, } impl From> for OpenIdProviderNoSecrets { @@ -335,6 +336,7 @@ impl From> for OpenIdProviderNoSecrets { directory_sync_target: value.directory_sync_target, okta_dirsync_client_id: value.okta_dirsync_client_id, directory_sync_group_match: value.directory_sync_group_match, + directory_sync_user_groups: value.directory_sync_user_groups, } } } diff --git a/crates/defguard_core/src/db/models/enrollment.rs b/crates/defguard_core/src/db/models/enrollment.rs index a7608c4292..c02fc70ea1 100644 --- a/crates/defguard_core/src/db/models/enrollment.rs +++ b/crates/defguard_core/src/db/models/enrollment.rs @@ -8,12 +8,13 @@ use defguard_common::{ random::gen_alphanumeric, types::UrlParseError, }; -use defguard_mail::templates; use sqlx::{PgConnection, PgExecutor, PgPool, query, query_as}; use tera::Context; use thiserror::Error; use tonic::{Code, Status}; +use crate::mail::templates; + pub static ENROLLMENT_TOKEN_TYPE: &str = "ENROLLMENT"; pub static PASSWORD_RESET_TOKEN_TYPE: &str = "PASSWORD_RESET"; diff --git a/crates/defguard_core/src/enrollment_management.rs b/crates/defguard_core/src/enrollment_management.rs index 6da9611a60..b3e998b4a5 100644 --- a/crates/defguard_core/src/enrollment_management.rs +++ b/crates/defguard_core/src/enrollment_management.rs @@ -2,11 +2,13 @@ use defguard_common::db::{ Id, models::{Settings, user::User}, }; -use defguard_mail::templates::{desktop_start_mail, new_account_mail}; use reqwest::Url; use sqlx::{PgConnection, PgExecutor, PgPool}; -use crate::db::models::enrollment::{ENROLLMENT_TOKEN_TYPE, Token, TokenError}; +use crate::{ + db::models::enrollment::{ENROLLMENT_TOKEN_TYPE, Token, TokenError}, + mail::templates::{desktop_start_mail, new_account_mail}, +}; /// Start user enrollment process /// This creates a new enrollment token valid for the duration specified by `token_timeout_seconds` diff --git a/crates/defguard_core/src/enterprise/db/models/openid_provider.rs b/crates/defguard_core/src/enterprise/db/models/openid_provider.rs index 47551828df..9ee9818f23 100644 --- a/crates/defguard_core/src/enterprise/db/models/openid_provider.rs +++ b/crates/defguard_core/src/enterprise/db/models/openid_provider.rs @@ -124,6 +124,9 @@ pub struct OpenIdProvider { // Fetch all users from directory and create them in Defguard // TODO: currently only supported for Microsoft pub prefetch_users: bool, + #[model(option_ref)] + // If set, only users who are members of these groups will be imported by the user prefetch + pub directory_sync_user_groups: Option>, } impl OpenIdProvider { @@ -148,6 +151,7 @@ impl OpenIdProvider { directory_sync_group_match: Vec, jumpcloud_api_key: Option, prefetch_users: bool, + directory_sync_user_groups: Option>, ) -> Self { Self { id: NoId, @@ -170,6 +174,7 @@ impl OpenIdProvider { directory_sync_group_match, jumpcloud_api_key, prefetch_users, + directory_sync_user_groups, } } @@ -182,8 +187,9 @@ impl OpenIdProvider { directory_sync_interval = $11, directory_sync_user_behavior = $12, \ directory_sync_admin_behavior = $13, directory_sync_target = $14, \ okta_private_jwk = $15, okta_dirsync_client_id = $16, \ - directory_sync_group_match = $17, jumpcloud_api_key = $18, prefetch_users = $19 \ - WHERE id = $20", + directory_sync_group_match = $17, jumpcloud_api_key = $18, prefetch_users = $19, \ + directory_sync_user_groups = $20 \ + WHERE id = $21", self.name, self.base_url, self.kind as OpenIdProviderKind, @@ -203,6 +209,7 @@ impl OpenIdProvider { &self.directory_sync_group_match, self.jumpcloud_api_key, self.prefetch_users, + self.directory_sync_user_groups.as_deref(), provider.id, ) .execute(pool) @@ -227,7 +234,8 @@ impl OpenIdProvider { directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", \ directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", \ directory_sync_target \"directory_sync_target: DirectorySyncTarget\", \ - okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key, prefetch_users \ + okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key, prefetch_users, \ + directory_sync_user_groups \ FROM openidprovider WHERE name = $1", name ) @@ -246,7 +254,8 @@ impl OpenIdProvider { directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", \ directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", \ directory_sync_target \"directory_sync_target: DirectorySyncTarget\", \ - okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key, prefetch_users \ + okta_private_jwk, okta_dirsync_client_id, directory_sync_group_match, jumpcloud_api_key, prefetch_users, \ + directory_sync_user_groups \ FROM openidprovider LIMIT 1" ) .fetch_optional(executor) diff --git a/crates/defguard_core/src/enterprise/directory_sync/mod.rs b/crates/defguard_core/src/enterprise/directory_sync/mod.rs index 6276f38d66..586591ecd1 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/mod.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/mod.rs @@ -451,6 +451,19 @@ pub async fn sync_user_groups_if_configured( Ok(()) } +/// Checks whether a user with the given email address is a member of at least one of the +/// given groups in the directory. +pub(crate) async fn user_in_directory_groups( + pool: &PgPool, + user_email: &str, + groups: &[String], +) -> Result { + let mut dir_sync = DirectorySyncClient::build(pool).await?; + dir_sync.prepare().await?; + let user_groups = dir_sync.get_user_groups(user_email).await?; + Ok(user_groups.iter().any(|group| groups.contains(&group.name))) +} + /// Create a group if it doesn't exist and add a user to it if they are not already a member async fn create_and_add_to_group( user: &User, @@ -630,6 +643,7 @@ async fn sync_all_users_state( gateway_tx: &Sender, ldap_tx: &UnboundedSender, all_users: &[DirectoryUser], + prefetch_allowed_emails: Option>, ) -> Result<(), DirectorySyncError> { info!("Syncing all users' state with the directory, this may take a while..."); let mut transaction = pool.begin().await?; @@ -642,13 +656,22 @@ async fn sync_all_users_state( let admin_behavior = settings.directory_sync_admin_behavior; let prefetch_users = settings.prefetch_users; + let is_allowed_user = |user: &DirectoryUser| -> bool { + prefetch_allowed_emails + .as_ref() + .is_none_or(|allowed| allowed.contains(&user.email)) + }; + // split directory users into separate lists for active and inactive users - let (active_directory_users, inactive_directory_users): (Vec<_>, Vec<_>) = - all_users.iter().partition(|user| user.active); + let (active_directory_users, inactive_directory_users): (Vec<_>, Vec<_>) = all_users + .iter() + .filter(|user| is_allowed_user(user)) + .partition(|user| user.active); // prepare a list of user emails for matching users between directory and Defguard let all_directory_emails = all_users .iter() + .filter(|user| is_allowed_user(user)) .map(|u| u.email.as_str()) .collect::>(); @@ -688,9 +711,11 @@ async fn sync_all_users_state( .collect(); // find all directory users not present in Defguard + // if user group filter is configured, only include users who are members of those groups let missing_defguard_users: Vec<_> = all_users .iter() .filter(|user| !existing_user_emails.contains(&user.email.as_str())) + .filter(|user| is_allowed_user(user)) .collect(); let core_settings = Settings::get_current_settings(); @@ -1037,9 +1062,14 @@ pub(crate) async fn do_directory_sync( return Ok(()); } - let sync_target = provider - .ok_or(DirectorySyncError::NotConfigured)? - .directory_sync_target; + let provider = provider.ok_or(DirectorySyncError::NotConfigured)?; + + let sync_target = provider.directory_sync_target; + let prefetch_users = provider.prefetch_users; + let user_groups_filter = provider + .directory_sync_user_groups + .clone() + .unwrap_or_default(); match DirectorySyncClient::build(pool).await { Ok(mut dir_sync) => { @@ -1057,7 +1087,52 @@ pub(crate) async fn do_directory_sync( DirectorySyncTarget::All | DirectorySyncTarget::Users ) { let users = dir_sync.get_all_users().await?; - sync_all_users_state(pool, gateway_tx, ldap_tx, &users).await?; + + // If prefetch is enabled and a user group filter is configured, build a set + // of emails of users who are members of those groups. Only those users will + // be imported by the prefetch. When the filter is empty we pass None and + // import everyone. + let prefetch_allowed_emails = if prefetch_users && !user_groups_filter.is_empty() { + let groups = dir_sync.get_groups().await?; + // get_groups() may itself be limited by the membership sync group filter (directory_sync_group_match), + // so groups configured here must also be included there if that filter is in use. + for group_name in &user_groups_filter { + if !groups.iter().any(|group| &group.name == group_name) { + warn!( + "Group '{group_name}' configured for user prefetch was not found among the directory groups, its members won't be imported. + Make sure the group name is correct and that it's also included in the membership sync group filter, if one is defined." + ); + } + } + let mut emails = HashSet::new(); + for group in groups + .iter() + .filter(|group| user_groups_filter.contains(&group.name)) + { + match dir_sync.get_group_members(group, Some(&users)).await { + Ok(members) => { + debug!( + "Adding {} members of group '{}' to the prefetch", + members.len(), + group.name + ); + emails.extend(members); + } + Err(err) => { + error!( + "Failed to get members of group '{}' for the prefetch filter: {err}", + group.name + ); + } + } + } + Some(emails) + } else { + None + }; + + sync_all_users_state(pool, gateway_tx, ldap_tx, &users, prefetch_allowed_emails) + .await?; all_users = Some(users); } if matches!( diff --git a/crates/defguard_core/src/enterprise/directory_sync/tests.rs b/crates/defguard_core/src/enterprise/directory_sync/tests.rs index e5831edda3..6513553cdc 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/tests.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/tests.rs @@ -1,6 +1,6 @@ #[cfg(test)] mod test { - use std::str::FromStr; + use std::{collections::HashSet, str::FromStr}; use defguard_common::{ config::{DefGuardConfig, SERVER_CONFIG}, @@ -105,6 +105,7 @@ mod test { Vec::new(), None, prefetch_users, + None, ) .save(pool) .await @@ -185,7 +186,7 @@ mod test { let all_users = client.get_all_users().await.unwrap(); let (ldap_tx, _ldap_rx) = ldap_test_channel(); - sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users) + sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users, None) .await .unwrap(); @@ -227,7 +228,7 @@ mod test { let all_users = client.get_all_users().await.unwrap(); let (ldap_tx, _ldap_rx) = ldap_test_channel(); - sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users) + sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users, None) .await .unwrap(); @@ -274,7 +275,7 @@ mod test { assert!(get_test_user(&pool, "testuser").await.is_some()); let all_users = client.get_all_users().await.unwrap(); let (ldap_tx, _ldap_rx) = ldap_test_channel(); - sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users) + sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users, None) .await .unwrap(); @@ -329,7 +330,7 @@ mod test { assert!(get_test_user(&pool, "testuser").await.is_some()); let all_users = client.get_all_users().await.unwrap(); let (ldap_tx, _ldap_rx) = ldap_test_channel(); - sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users) + sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users, None) .await .unwrap(); @@ -417,7 +418,7 @@ mod test { let all_users = client.get_all_users().await.unwrap(); let (ldap_tx, _ldap_rx) = ldap_test_channel(); - sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users) + sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users, None) .await .unwrap(); @@ -491,7 +492,7 @@ mod test { let all_users = client.get_all_users().await.unwrap(); let (ldap_tx, _ldap_rx) = ldap_test_channel(); - sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users) + sync_all_users_state(&pool, &gateway_tx, &ldap_tx, &all_users, None) .await .unwrap(); @@ -878,6 +879,155 @@ mod test { assert!(gateway_rx.try_recv().is_err()); } + #[sqlx::test] + async fn test_users_prefetch_group_filter(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (gateway_tx, mut gateway_rx) = broadcast::channel::(16); + + // enable prefetching users, import only members of group1 + let mut provider = make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + true, + ) + .await; + provider.directory_sync_user_groups = Some(vec!["group1".to_string()]); + provider.save(&pool).await.unwrap(); + + // no users in Defguard before sync + let defguard_users = User::all(&pool).await.unwrap(); + assert!(defguard_users.is_empty()); + + let (ldap_tx, _ldap_rx) = ldap_test_channel(); + do_directory_sync(&pool, &gateway_tx, &ldap_tx) + .await + .unwrap(); + + // all directory users are members of group1, so all of them were imported + let defguard_users = User::all(&pool).await.unwrap(); + assert_eq!(defguard_users.len(), 3); + + // No events + assert!(gateway_rx.try_recv().is_err()); + } + + #[sqlx::test] + async fn test_users_prefetch_group_filter_no_match( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (gateway_tx, mut gateway_rx) = broadcast::channel::(16); + + // enable prefetching users, import only members of a group that doesn't exist + // in the directory + let mut provider = make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::Users, + true, + ) + .await; + provider.directory_sync_user_groups = Some(vec!["nonexistent-group".to_string()]); + provider.save(&pool).await.unwrap(); + + let (ldap_tx, _ldap_rx) = ldap_test_channel(); + do_directory_sync(&pool, &gateway_tx, &ldap_tx) + .await + .unwrap(); + + // no users were imported + let defguard_users = User::all(&pool).await.unwrap(); + assert!(defguard_users.is_empty()); + + // No events + assert!(gateway_rx.try_recv().is_err()); + } + + #[sqlx::test] + async fn test_users_prefetch_allowed_emails(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + let (gateway_tx, mut gateway_rx) = broadcast::channel::(16); + + // enable prefetching users + make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + true, + ) + .await; + let mut client = DirectorySyncClient::build(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + // no users in Defguard before sync + let defguard_users = User::all(&pool).await.unwrap(); + assert!(defguard_users.is_empty()); + + // only allow one of the directory users to be imported + let allowed_emails = HashSet::from(["testuser@email.com".to_string()]); + let all_users = client.get_all_users().await.unwrap(); + let (ldap_tx, _ldap_rx) = ldap_test_channel(); + sync_all_users_state( + &pool, + &gateway_tx, + &ldap_tx, + &all_users, + Some(allowed_emails), + ) + .await + .unwrap(); + + // only the allowed user was imported + let defguard_users = User::all(&pool).await.unwrap(); + assert_eq!(defguard_users.len(), 1); + assert_eq!(defguard_users[0].email, "testuser@email.com"); + + // No events + assert!(gateway_rx.try_recv().is_err()); + } + + #[sqlx::test] + async fn test_user_in_directory_groups(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + false, + ) + .await; + + // the test provider returns group1 as the only group of any user + assert!( + user_in_directory_groups(&pool, "testuser@email.com", &["group1".to_string()]) + .await + .unwrap() + ); + assert!( + !user_in_directory_groups(&pool, "testuser@email.com", &["group2".to_string()]) + .await + .unwrap() + ); + } + #[sqlx::test] async fn test_users_prefetch_respects_license_user_limit( _: PgPoolOptions, diff --git a/crates/defguard_core/src/enterprise/handlers/openid_login.rs b/crates/defguard_core/src/enterprise/handlers/openid_login.rs index 7207bb48ce..cf5f0a51fb 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_login.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_login.rs @@ -36,8 +36,8 @@ use super::LicenseInfo; use crate::{ appstate::AppState, enterprise::{ - db::models::openid_provider::OpenIdProvider, - directory_sync::sync_user_groups_if_configured, + db::models::openid_provider::{OpenIdProvider, OpenIdProviderKind}, + directory_sync::{sync_user_groups_if_configured, user_in_directory_groups}, ldap::utils::ldap_update_user_state, license::get_cached_license, limits::{get_counts, update_counts}, @@ -340,6 +340,38 @@ pub async fn user_from_claims( )); } + // If user synchronization is enabled and limited to specific directory groups, only allow + // creating accounts for users who are members of one of those groups. + if provider.directory_sync_enabled + && let Some(user_groups_filter) = provider + .directory_sync_user_groups + .as_ref() + .filter(|groups| !groups.is_empty()) + && provider.kind == OpenIdProviderKind::Microsoft + { + let in_groups = user_in_directory_groups(pool, email, user_groups_filter) + .await + .map_err(|err| { + error!( + "Failed to check directory group membership of user with email address {} during OpenID account creation: {err}", + email.as_str() + ); + WebError::Authorization( + "Failed to verify user's directory group membership".into(), + ) + })?; + if !in_groups { + warn!( + "User with email address {} is trying to log in for the first time but is not a member of any + directory groups configured for user synchronization. Blocking account creation.", + email.as_str() + ); + return Err(WebError::UserGroupsNotSynced( + "User is not a member of any of the directory groups allowed for account creation".into(), + )); + } + } + // Try to get the username from `preferred_username` claim. // If it's not there, extract it from email. let username = if let Some(username) = token_claims.preferred_username() { diff --git a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs index 3e01bab023..c298436c16 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_providers.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_providers.rs @@ -45,6 +45,7 @@ pub struct AddProviderData { pub directory_sync_group_match: Option, pub jumpcloud_api_key: Option, pub prefetch_users: bool, + pub directory_sync_user_groups: Option, // Core settings pub create_account: bool, pub username_handling: OpenIdUsernameHandling, @@ -156,6 +157,21 @@ pub(crate) async fn add_openid_provider( Vec::new() }; + let user_groups = if let Some(user_groups) = provider_data.directory_sync_user_groups { + if user_groups.is_empty() { + None + } else { + Some( + user_groups + .split(',') + .map(|s| s.trim().to_string()) + .collect(), + ) + } + } else { + None + }; + // Currently, we only support one OpenID provider at a time let new_provider = OpenIdProvider::new( provider_data.name, @@ -177,6 +193,7 @@ pub(crate) async fn add_openid_provider( group_match, provider_data.jumpcloud_api_key, provider_data.prefetch_users, + user_groups, ) .upsert(&appstate.pool) .await?; @@ -384,6 +401,21 @@ pub(crate) async fn modify_openid_provider( Vec::new() }; + let user_groups = if let Some(user_groups) = provider_data.directory_sync_user_groups { + if user_groups.is_empty() { + None + } else { + Some( + user_groups + .split(',') + .map(|s| s.trim().to_string()) + .collect(), + ) + } + } else { + None + }; + provider.base_url = provider_data.base_url; provider.kind = provider_data.kind; provider.client_id = provider_data.client_id; @@ -402,6 +434,7 @@ pub(crate) async fn modify_openid_provider( provider.directory_sync_group_match = group_match; provider.jumpcloud_api_key = provider_data.jumpcloud_api_key; provider.prefetch_users = provider_data.prefetch_users; + provider.directory_sync_user_groups = user_groups; provider.save(&mut *transaction).await?; transaction.commit().await?; diff --git a/crates/defguard_core/src/enterprise/mod.rs b/crates/defguard_core/src/enterprise/mod.rs index caa871030b..1ffaea4752 100644 --- a/crates/defguard_core/src/enterprise/mod.rs +++ b/crates/defguard_core/src/enterprise/mod.rs @@ -8,6 +8,7 @@ pub mod handlers; pub mod ldap; pub mod license; pub mod limits; +pub mod oauth2; pub mod posture; pub mod snat; mod utils; @@ -19,6 +20,9 @@ use strum::VariantArray; pub use crate::enterprise::license::LicenseFeature; use crate::enterprise::license::LicenseTier; +/// Shared HTTP request timeout for enterprise outbound calls (e.g. OAuth2 token endpoints). +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + /// Returns whether a valid license grants the given feature, considering both the tier baseline /// and any explicit additive flags. Does not check validity; call `validate_license` first. pub(crate) fn license_grants_feature(license: &License, feature: LicenseFeature) -> bool { diff --git a/crates/defguard_core/src/enterprise/oauth2/microsoft.rs b/crates/defguard_core/src/enterprise/oauth2/microsoft.rs new file mode 100644 index 0000000000..d6884da8e3 --- /dev/null +++ b/crates/defguard_core/src/enterprise/oauth2/microsoft.rs @@ -0,0 +1,68 @@ +use std::time::{Duration, SystemTime}; + +use serde::Deserialize; + +use super::super::REQUEST_TIMEOUT; + +const TOKEN_URL: &str = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"; +const GRANT_TYPE: &str = "client_credentials"; + +#[derive(Deserialize)] +pub struct TokenResponse { + // token_type: String, + access_token: String, + /// Absolute expiry time as a Unix timestamp (seconds since epoch) + expires_in: u64, +} + +impl TokenResponse { + #[must_use] + pub fn access_token(&self) -> String { + self.access_token.clone() + } + + #[must_use] + pub fn expires_in(&self) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(self.expires_in) + } +} + +pub struct MicrosoftOAuth2 { + client_id: String, + client_secret: String, + tenant_id: String, + scope: String, +} + +impl MicrosoftOAuth2 { + #[must_use] + pub fn new(client_id: String, client_secret: String, tenant_id: String, scope: String) -> Self { + Self { + client_id, + client_secret, + tenant_id, + scope, + } + } + + pub async fn fetch_access_token(&self) -> Result { + debug!("Querying Microsoft for OAuth2 access token."); + let token_url = TOKEN_URL.replace("{tenant_id}", &self.tenant_id); + let client = reqwest::Client::new(); + let response = client + .post(&token_url) + .form(&[ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("scope", self.scope.as_str()), + ("grant_type", GRANT_TYPE), + ]) + .timeout(REQUEST_TIMEOUT) + .send() + .await?; + let token_response = response.json::().await?; + debug!("Fetched Microsoft OAuth2 access token"); + + Ok(token_response) + } +} diff --git a/crates/defguard_core/src/enterprise/oauth2/mod.rs b/crates/defguard_core/src/enterprise/oauth2/mod.rs new file mode 100644 index 0000000000..590d854798 --- /dev/null +++ b/crates/defguard_core/src/enterprise/oauth2/mod.rs @@ -0,0 +1,124 @@ +pub mod microsoft; + +use defguard_common::db::models::settings::smtp::SmtpSettings; +use openidconnect::{ + ClientId, ClientSecret, IssuerUrl, OAuth2TokenResponse, RefreshToken, + core::{CoreClient, CoreProviderMetadata}, + reqwest::{ClientBuilder, redirect::Policy}, +}; +use tracing::{debug, error}; + +use self::microsoft::MicrosoftOAuth2; +use super::is_business_license_active; + +const OUTLOOK_DEFAULT_SCOPE: &str = "https://outlook.office365.com/.default"; + +#[derive(Debug, thiserror::Error)] +pub enum OAuth2Error { + #[error("OAuth2 not configured")] + NotConfigured, + #[error(transparent)] + Configuration(#[from] openidconnect::ConfigurationError), + #[error("Open ID discovery")] + OpenIDDiscovery, + #[error("Refresh token exchange")] + RefreshTokenExchange, + #[error(transparent)] + Reqwest(#[from] openidconnect::reqwest::Error), + #[error(transparent)] + Url(#[from] openidconnect::url::ParseError), +} + +/// Obtain access token from Google. +async fn google_access_token(smtp_settings: &mut SmtpSettings) -> Result { + let (Some(issuer_url), Some(client_id), Some(client_secret), Some(refresh_token)) = ( + &smtp_settings.oauth_issuer_url, + &smtp_settings.oauth_client_id, + &smtp_settings.oauth_client_secret, + &smtp_settings.oauth_refresh_token, + ) else { + error!("Google SMTP XOAUTH2 requires: issuer URL, client ID, client secret, refresh token"); + return Err(OAuth2Error::NotConfigured); + }; + let issuer_url = IssuerUrl::new(issuer_url.into())?; + let client_id = ClientId::new(client_id.into()); + let client_secret = ClientSecret::new(client_secret.expose_secret().into()); + let refresh_token = RefreshToken::new(refresh_token.into()); + + let http_client = ClientBuilder::new() + // Following redirects opens the client up to SSRF vulnerabilities. + .redirect(Policy::none()) + .build()?; + + let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, &http_client) + .await + .map_err(|err| { + error!("Failed OpenID Connect Discovery: {err}"); + OAuth2Error::OpenIDDiscovery + })?; + + let client = + CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); + + let token_response = client + .exchange_refresh_token(&refresh_token)? + .request_async(&http_client) + .await + .map_err(|err| { + error!("Failed to fetch token: {err}"); + OAuth2Error::RefreshTokenExchange + })?; + + let access_token = token_response.access_token().secret(); + debug!("Got access token"); + if let Some(expires_in) = token_response.expires_in() { + debug!("Access token expires in {expires_in:?}"); + } + if let Some(refresh_token) = token_response.refresh_token() { + debug!("Got refresh token"); + // TODO: use `self.set_oauth_refresh_token` + smtp_settings.oauth_refresh_token = Some(refresh_token.secret().into()); + } + Ok(access_token.clone()) +} + +/// Obtain access token from Microsoft. +async fn microsoft_access_token(smtp_settings: &mut SmtpSettings) -> Result { + let (Some(client_id), Some(client_secret), Some(tenant_id)) = ( + &smtp_settings.oauth_client_id, + &smtp_settings.oauth_client_secret, + &smtp_settings.oauth_tenant_id, + ) else { + error!("Microsoft SMTP XOAUTH2 requires: tenant ID, client ID, client secret"); + return Err(OAuth2Error::NotConfigured); + }; + + let oauth2 = MicrosoftOAuth2::new( + client_id.clone(), + client_secret.expose_secret().to_string(), + tenant_id.clone(), + OUTLOOK_DEFAULT_SCOPE.into(), + ); + let token = oauth2.fetch_access_token().await?; + + Ok(token.access_token()) +} + +/// Obtain access token for XOAUTH2 authentication. +pub async fn xoauth2_access_token(smtp_settings: &mut SmtpSettings) -> Result { + if !is_business_license_active() { + error!("SMTP XOAUTH2 requires business license"); + } else if let Some(issuer_url) = &smtp_settings.oauth_issuer_url { + // FIXME: baked URLs + if issuer_url == "https://login.microsoftonline.com/common" { + return microsoft_access_token(smtp_settings).await; + } + if issuer_url == "https://accounts.google.com" { + return google_access_token(smtp_settings).await; + } + } else { + error!("SMTP XOAUTH2 requires: issuer URL"); + } + + Err(OAuth2Error::NotConfigured) +} diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index fb90765672..03bb24490f 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -7,7 +7,6 @@ use defguard_common::{ }, types::UrlParseError, }; -use defguard_mail::templates::TemplateError; use defguard_static_ip::error::StaticIpError; use thiserror::Error; use tokio::sync::mpsc::error::SendError; @@ -24,6 +23,7 @@ use crate::{ events::ApiEvent, handlers::{openid_flow::OidcFlowError, user::ValidationError}, location_management::LocationManagementError, + mail::templates::TemplateError, user_management::UserManagementError, }; @@ -44,6 +44,8 @@ pub enum WebError { Serialization(String), #[error("Authorization error: {0}")] Authorization(String), + #[error("User groups not synced: {0}")] + UserGroupsNotSynced(String), #[error("Authentication error")] Authentication, #[error("Forbidden error: {0}")] diff --git a/crates/defguard_core/src/grpc/proxy/client_mfa.rs b/crates/defguard_core/src/grpc/proxy/client_mfa.rs index 073bb5f4d2..7d72b0362d 100644 --- a/crates/defguard_core/src/grpc/proxy/client_mfa.rs +++ b/crates/defguard_core/src/grpc/proxy/client_mfa.rs @@ -18,7 +18,6 @@ use defguard_common::{ }, types::user_info::UserInfo, }; -use defguard_mail::templates::mfa_code_mail; use defguard_proto::{ client_types::{ ClientMfaFinishRequest, ClientMfaFinishResponse, ClientMfaStartRequest, @@ -51,6 +50,7 @@ use crate::{ }, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent}, grpc::{GatewayCommand, utils::parse_client_ip_agent}, + mail::templates::mfa_code_mail, }; const CLIENT_SESSION_TIMEOUT: u64 = 60 * 5; // 5 minutes diff --git a/crates/defguard_core/src/handlers/app_info.rs b/crates/defguard_core/src/handlers/app_info.rs index c335cc5e95..e4fd3cf2dd 100644 --- a/crates/defguard_core/src/handlers/app_info.rs +++ b/crates/defguard_core/src/handlers/app_info.rs @@ -6,7 +6,9 @@ use defguard_common::{ use super::{ApiResponse, ApiResult}; use crate::{ - appstate::AppState, auth::SessionInfo, enterprise::db::models::openid_provider::OpenIdProvider, + appstate::AppState, + auth::SessionInfo, + enterprise::{db::models::openid_provider::OpenIdProvider, is_business_license_active}, }; #[derive(Serialize)] @@ -33,10 +35,15 @@ pub async fn get_app_info(State(appstate): State, _session: SessionInf let external_openid_enabled = OpenIdProvider::get_current(&appstate.pool).await?.is_some(); let settings = Settings::get_current_settings(); + let mut smtp_enabled = settings.smtp_configured(); + // XOAUTH2 is only for the business licence. + if settings.smtp.is_xoauth2() && !is_business_license_active() { + smtp_enabled = false; + } let res = AppInfo { network_present: !networks.is_empty(), - smtp_enabled: settings.smtp_configured(), + smtp_enabled, version: VERSION.into(), ldap_info: LdapInfo { enabled: settings.ldap_enabled, diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 696ad58945..330297fb9d 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -19,7 +19,6 @@ use defguard_common::{ }, types::user_info::UserInfo, }; -use defguard_mail::templates::{mfa_activation_mail, mfa_code_mail, mfa_configured_mail}; use sqlx::{PgPool, types::Uuid}; use time::Duration; use uaparser::Parser; @@ -41,6 +40,7 @@ use crate::{ events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ClientIpAddr, SIGN_IN_COOKIE_NAME, cookie_domain, user_for_admin_or_self}, headers::{USER_AGENT_PARSER, check_new_device_login, get_user_agent_device}, + mail::templates::{mfa_activation_mail, mfa_code_mail, mfa_configured_mail}, server_config, }; diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index 9a0f35c67e..b2aa054849 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -4,10 +4,6 @@ use axum::{ }; use chrono::Utc; use defguard_common::db::models::{User, gateway::Gateway, proxy::Proxy}; -use defguard_mail::{ - mail::Attachment, - templates::{self, SUPPORT_EMAIL_ADDRESS}, -}; use serde_json::json; use sqlx::query_scalar; use tera::Context; @@ -19,6 +15,10 @@ use crate::{ PgPool, appstate::AppState, auth::{AdminRole, SessionInfo}, + mail::{ + Attachment, + templates::{self, SUPPORT_EMAIL_ADDRESS}, + }, server_config, support::dump_config, }; @@ -134,7 +134,7 @@ pub enum MailError { #[error("Database error: {0}")] Db(#[from] sqlx::Error), #[error("Template error: {0}")] - Template(#[from] defguard_mail::templates::TemplateError), + Template(#[from] crate::mail::templates::TemplateError), } pub async fn send_gateway_disconnected_email( diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 4c5c9fdab4..bf4cbe1633 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -66,6 +66,7 @@ pub(crate) mod yubikey; #[serde(rename_all = "snake_case")] pub enum WebErrorCode { NetworkFull, + UserGroupsNotSynced, } pub static SESSION_COOKIE_NAME: &str = "defguard_session"; @@ -288,6 +289,13 @@ impl From for ApiResponse { StatusCode::BAD_REQUEST, ) } + WebError::UserGroupsNotSynced(msg) => { + warn!(msg); + Self::new( + json!({"msg": msg, "code": WebErrorCode::UserGroupsNotSynced}), + StatusCode::UNAUTHORIZED, + ) + } WebError::TemplateError(err) => { error!("Template error: {err}"); Self::new( diff --git a/crates/defguard_core/src/handlers/network_devices.rs b/crates/defguard_core/src/handlers/network_devices.rs index 1b9d343ba5..375d824677 100644 --- a/crates/defguard_core/src/handlers/network_devices.rs +++ b/crates/defguard_core/src/handlers/network_devices.rs @@ -20,7 +20,6 @@ use defguard_common::{ }, utils::{SplitIp, split_ip}, }; -use defguard_mail::templates::{TemplateLocation, new_device_added_mail}; use serde_json::json; use sqlx::PgConnection; @@ -40,6 +39,7 @@ use crate::{ device_for_admin_or_self, pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationParams}, }, + mail::templates::{TemplateLocation, new_device_added_mail}, }; #[derive(Serialize)] diff --git a/crates/defguard_core/src/handlers/openid_flow.rs b/crates/defguard_core/src/handlers/openid_flow.rs index 1fe4334450..299514e5c4 100644 --- a/crates/defguard_core/src/handlers/openid_flow.rs +++ b/crates/defguard_core/src/handlers/openid_flow.rs @@ -22,7 +22,6 @@ use defguard_common::db::{ oauth2client::OAuth2Client, }, }; -use defguard_mail::templates::new_device_oidc_login_mail; use openidconnect::{ AccessToken, AdditionalClaims, Audience, AuthUrl, AuthorizationCode, EmptyAdditionalProviderMetadata, EmptyExtraTokenFields, EndUserEmail, EndUserFamilyName, @@ -51,6 +50,7 @@ use crate::{ auth::{SessionInfo, UserClaims}, error::WebError, handlers::{SIGN_IN_COOKIE_MAX_AGE, SIGN_IN_COOKIE_NAME, cookie_domain}, + mail::templates::new_device_oidc_login_mail, server_config, }; diff --git a/crates/defguard_core/src/handlers/user.rs b/crates/defguard_core/src/handlers/user.rs index 9b06d5e198..9dfce2cf9c 100644 --- a/crates/defguard_core/src/handlers/user.rs +++ b/crates/defguard_core/src/handlers/user.rs @@ -12,7 +12,6 @@ use defguard_common::{ }, types::{group_diff::GroupDiff, user_info::UserInfo}, }; -use defguard_mail::templates; use humantime::parse_duration; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -51,6 +50,7 @@ use crate::{ events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::pagination::{PaginatedApiResponse, PaginatedApiResult, PaginationParams}, is_valid_phone_number, + mail::templates, user_management::{delete_user_and_cleanup_devices, disable_user, sync_allowed_user_devices}, }; diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index 4b516f400b..01a5143a8a 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -16,7 +16,6 @@ use defguard_common::{ }, utils::parse_network_address_list, }; -use defguard_mail::templates::{TemplateLocation, new_device_added_mail}; use ipnetwork::IpNetwork; use serde_json::{Value, json}; use sqlx::PgPool; @@ -45,6 +44,7 @@ use crate::{ allowed_peers::get_location_allowed_peers, handle_imported_devices, handle_mapped_devices, sync_location_allowed_devices, }, + mail::templates::{TemplateLocation, new_device_added_mail}, wg_config::{ImportedDevice, parse_wireguard_config}, }; diff --git a/crates/defguard_core/src/headers.rs b/crates/defguard_core/src/headers.rs index 24b9757714..5df00a26af 100644 --- a/crates/defguard_core/src/headers.rs +++ b/crates/defguard_core/src/headers.rs @@ -16,10 +16,11 @@ use defguard_common::db::{ Id, models::{DeviceLoginEvent, User}, }; -use defguard_mail::templates::{SessionContext, TemplateError, new_device_login_mail}; use sqlx::PgPool; use uaparser::{Client, Parser, UserAgentParser}; +use crate::mail::templates::{SessionContext, TemplateError, new_device_login_mail}; + // Header name constants not yet present in the `http` crate v1.x standard set. const PERMISSIONS_POLICY: HeaderName = HeaderName::from_static("permissions-policy"); const CROSS_ORIGIN_OPENER_POLICY: HeaderName = diff --git a/crates/defguard_core/src/letsencrypt.rs b/crates/defguard_core/src/letsencrypt.rs index ef1a00b8f3..168522962d 100644 --- a/crates/defguard_core/src/letsencrypt.rs +++ b/crates/defguard_core/src/letsencrypt.rs @@ -10,7 +10,6 @@ use defguard_common::{ types::proxy::ProxyControlMessage, }; use defguard_grpc_tls::certs::proxy_mtls_channel; -use defguard_mail::templates; use defguard_proto::proxy::{ AcmeChallenge, AcmeLogs, AcmeStep, acme_issue_event, proxy_client::ProxyClient, }; @@ -26,6 +25,8 @@ use tokio::{ }; use tonic::{Request, service::Interceptor}; +use crate::mail::templates; + /// Maximum time (seconds) allowed for the ACME flow to complete end-to-end. #[cfg(not(test))] pub const ACME_TIMEOUT: Duration = Duration::from_secs(300); diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index bdd8bc450b..9e31821425 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -208,6 +208,7 @@ pub mod handlers; pub mod headers; pub mod letsencrypt; pub mod location_management; +pub mod mail; pub mod setup_logs; pub mod support; pub mod updates; diff --git a/crates/defguard_mail/assets/apple.png b/crates/defguard_core/src/mail/assets/apple.png similarity index 100% rename from crates/defguard_mail/assets/apple.png rename to crates/defguard_core/src/mail/assets/apple.png diff --git a/crates/defguard_mail/assets/date.png b/crates/defguard_core/src/mail/assets/date.png similarity index 100% rename from crates/defguard_mail/assets/date.png rename to crates/defguard_core/src/mail/assets/date.png diff --git a/crates/defguard_mail/assets/defguard.png b/crates/defguard_core/src/mail/assets/defguard.png similarity index 100% rename from crates/defguard_mail/assets/defguard.png rename to crates/defguard_core/src/mail/assets/defguard.png diff --git a/crates/defguard_mail/assets/github.png b/crates/defguard_core/src/mail/assets/github.png similarity index 100% rename from crates/defguard_mail/assets/github.png rename to crates/defguard_core/src/mail/assets/github.png diff --git a/crates/defguard_mail/assets/google_play.png b/crates/defguard_core/src/mail/assets/google_play.png similarity index 100% rename from crates/defguard_mail/assets/google_play.png rename to crates/defguard_core/src/mail/assets/google_play.png diff --git a/crates/defguard_mail/assets/mastodon.png b/crates/defguard_core/src/mail/assets/mastodon.png similarity index 100% rename from crates/defguard_mail/assets/mastodon.png rename to crates/defguard_core/src/mail/assets/mastodon.png diff --git a/crates/defguard_mail/assets/new_account_1.png b/crates/defguard_core/src/mail/assets/new_account_1.png similarity index 100% rename from crates/defguard_mail/assets/new_account_1.png rename to crates/defguard_core/src/mail/assets/new_account_1.png diff --git a/crates/defguard_mail/assets/new_account_2.png b/crates/defguard_core/src/mail/assets/new_account_2.png similarity index 100% rename from crates/defguard_mail/assets/new_account_2.png rename to crates/defguard_core/src/mail/assets/new_account_2.png diff --git a/crates/defguard_mail/assets/otp.png b/crates/defguard_core/src/mail/assets/otp.png similarity index 100% rename from crates/defguard_mail/assets/otp.png rename to crates/defguard_core/src/mail/assets/otp.png diff --git a/crates/defguard_mail/assets/x.png b/crates/defguard_core/src/mail/assets/x.png similarity index 100% rename from crates/defguard_mail/assets/x.png rename to crates/defguard_core/src/mail/assets/x.png diff --git a/crates/defguard_mail/src/mail_context.rs b/crates/defguard_core/src/mail/mail_context.rs similarity index 100% rename from crates/defguard_mail/src/mail_context.rs rename to crates/defguard_core/src/mail/mail_context.rs diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_core/src/mail/mod.rs similarity index 73% rename from crates/defguard_mail/src/mail.rs rename to crates/defguard_core/src/mail/mod.rs index e4a319961f..b181bd1492 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_core/src/mail/mod.rs @@ -1,3 +1,15 @@ +//! Handle email messages. +//! +//! Refer to: +//! - [RFC 2557](https://datatracker.ietf.org/doc/html/rfc2557) +//! - [Meaning of mulitpart](https://www.codestudy.net/blog/mail-multipart-alternative-vs-multipart-mixed/) + +pub(crate) mod mail_context; +mod qr; +pub mod templates; +#[cfg(test)] +mod tests; + use std::{str::FromStr, time::Duration}; use defguard_common::db::models::{ @@ -17,12 +29,36 @@ use sqlx::PgConnection; use tera::{Context, Tera, Value}; use tracing::{debug, error, info, warn}; -use super::{ - MailError, +use crate::enterprise::oauth2::xoauth2_access_token; + +#[derive(Debug, thiserror::Error)] +pub enum MailError { + #[error(transparent)] + Lettre(#[from] lettre::error::Error), + + #[error(transparent)] + Address(#[from] lettre::address::AddressError), + + #[error(transparent)] + Smtp(#[from] lettre::transport::smtp::Error), + + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + + #[error("SMTP not configured")] + SmtpNotConfigured, + + #[error("Invalid port: {0}")] + InvalidPort(i32), + + #[error(transparent)] + OAuth2(#[from] crate::enterprise::oauth2::OAuth2Error), +} + +use self::{ mail_context::MailContext, qr::qr_png, templates::{DEFAULT_LANG, TemplateError}, - xoauth2::obtain_access_token, }; #[derive(Debug)] @@ -48,18 +84,18 @@ impl From for SinglePart { const SMTP_TIMEOUT: Duration = Duration::from_secs(15); // Template images. -static DEFGUARD_LOGO: &[u8] = include_bytes!("../assets/defguard.png"); -static GITHUB_LOGO: &[u8] = include_bytes!("../assets/github.png"); -static MASTODON_LOGO: &[u8] = include_bytes!("../assets/mastodon.png"); -static X_LOGO: &[u8] = include_bytes!("../assets/x.png"); +static DEFGUARD_LOGO: &[u8] = include_bytes!("assets/defguard.png"); +static GITHUB_LOGO: &[u8] = include_bytes!("assets/github.png"); +static MASTODON_LOGO: &[u8] = include_bytes!("assets/mastodon.png"); +static X_LOGO: &[u8] = include_bytes!("assets/x.png"); // MFA code -static DATE_ICON: &[u8] = include_bytes!("../assets/date.png"); -static OTP_ICON: &[u8] = include_bytes!("../assets/otp.png"); +static DATE_ICON: &[u8] = include_bytes!("assets/date.png"); +static OTP_ICON: &[u8] = include_bytes!("assets/otp.png"); // New account -static NEW_ACCOUNT_1: &[u8] = include_bytes!("../assets/new_account_1.png"); -static NEW_ACCOUNT_2: &[u8] = include_bytes!("../assets/new_account_2.png"); -static GOOGLE_PLAY: &[u8] = include_bytes!("../assets/google_play.png"); -static APPLE: &[u8] = include_bytes!("../assets/apple.png"); +static NEW_ACCOUNT_1: &[u8] = include_bytes!("assets/new_account_1.png"); +static NEW_ACCOUNT_2: &[u8] = include_bytes!("assets/new_account_2.png"); +static GOOGLE_PLAY: &[u8] = include_bytes!("assets/google_play.png"); +static APPLE: &[u8] = include_bytes!("assets/apple.png"); /// Mail message #[derive(Debug)] @@ -78,7 +114,7 @@ pub struct Mail { impl Mail { /// Create new [`Mail`]. #[must_use] - pub fn new(to: T, subject: String, html: String, text: String) -> Self + pub fn new(to: T, subject: String, html: String, text: String) -> Mail where T: Into, { @@ -257,7 +293,7 @@ impl Mail { builder.credentials(Credentials::new(user, password.expose_secret().into())); } SmtpAuthentication::XOAuth2 => { - let code = obtain_access_token(&mut smtp_settings).await?; + let code = xoauth2_access_token(&mut smtp_settings).await?; let Some(sender) = smtp_settings.sender else { error!("XOAUTH2 requires sender email address"); return Err(MailError::SmtpNotConfigured); @@ -318,30 +354,30 @@ impl MailMessage { } } match self { - Self::Test => "Defguard: Test message".to_owned(), - Self::Welcome => WELCOME_EMAIL_SUBJECT.to_owned(), - Self::SupportData => "Defguard: Support data".to_owned(), - Self::DesktopStart => "Defguard: Desktop client configuration".to_owned(), - Self::NewAccount => "Defguard: User enrollment".to_owned(), - Self::NewDevice => "Defguard: new device added to your account".to_owned(), - Self::NewDeviceLogin => "Defguard: New device logged in to your account".to_owned(), - Self::NewDeviceOIDCLogin => "New login to OIDC application".to_owned(), - Self::GatewayDisconnect => "Defguard: Gateway disconnected".to_owned(), - Self::GatewayReconnect => "Defguard: Gateway reconnected".to_owned(), - Self::MFAActivation => "Multi-Factor Authentication activation".to_owned(), + Self::Test => "Defguard: Test message".to_string(), + Self::Welcome => WELCOME_EMAIL_SUBJECT.to_string(), + Self::SupportData => "Defguard: Support data".to_string(), + Self::DesktopStart => "Defguard: Desktop client configuration".to_string(), + Self::NewAccount => "Defguard: User enrollment".to_string(), + Self::NewDevice => "Defguard: new device added to your account".to_string(), + Self::NewDeviceLogin => "Defguard: New device logged in to your account".to_string(), + Self::NewDeviceOIDCLogin => "New login to OIDC application".to_string(), + Self::GatewayDisconnect => "Defguard: Gateway disconnected".to_string(), + Self::GatewayReconnect => "Defguard: Gateway reconnected".to_string(), + Self::MFAActivation => "Multi-Factor Authentication activation".to_string(), Self::MFAConfigured { method } => { format!("Multi-Factor Authentication {method} has been activated") } - Self::MFACode => "Defguard: Multi-Factor Authentication code for login".to_owned(), - Self::PasswordReset => "Defguard: Password reset".to_owned(), - Self::PasswordResetDone => "Defguard: Password reset success".to_owned(), - Self::UserImportBlocked => "User import blocked".to_owned(), - Self::EnrollmentNotification => "Defguard: User enrollment completed".to_owned(), + Self::MFACode => "Defguard: Multi-Factor Authentication code for login".to_string(), + Self::PasswordReset => "Defguard: Password reset".to_string(), + Self::PasswordResetDone => "Defguard: Password reset success".to_string(), + Self::UserImportBlocked => "User import blocked".to_string(), + Self::EnrollmentNotification => "Defguard: User enrollment completed".to_string(), Self::LetsencryptCertRefreshFailed => { - "Defguard: automatic Let's Encrypt certificate refresh failed".to_owned() + "Defguard: automatic Let's Encrypt certificate refresh failed".to_string() } - Self::CertificateExpiration => "Defguard: Certificate expiration".to_owned(), - Self::CertificateExpired => "Defguard: Certificate has expired".to_owned(), + Self::CertificateExpiration => "Defguard: Certificate expiration".to_string(), + Self::CertificateExpired => "Defguard: Certificate has expired".to_string(), } } @@ -372,60 +408,60 @@ impl MailMessage { pub(crate) const fn mjml_template(&self) -> &str { match self { - Self::Test => include_str!("../templates/test.mjml"), - Self::Welcome => include_str!("../templates/enrollment-welcome.mjml"), - Self::SupportData => include_str!("../templates/support-data.mjml"), - Self::DesktopStart => include_str!("../templates/desktop-start.mjml"), - Self::NewAccount => include_str!("../templates/new-account.mjml"), - Self::NewDevice => include_str!("../templates/new-device.mjml"), - Self::NewDeviceLogin => include_str!("../templates/new-device-login.mjml"), - Self::NewDeviceOIDCLogin => include_str!("../templates/new-device-oidc-login.mjml"), - Self::GatewayDisconnect => include_str!("../templates/gateway-disconnected.mjml"), - Self::GatewayReconnect => include_str!("../templates/gateway-reconnected.mjml"), - Self::MFAActivation => include_str!("../templates/mfa-activation.mjml"), - Self::MFAConfigured { method: _ } => include_str!("../templates/mfa-configured.mjml"), - Self::MFACode => include_str!("../templates/mfa-code.mjml"), - Self::PasswordReset => include_str!("../templates/password-reset.mjml"), - Self::PasswordResetDone => include_str!("../templates/password-reset-done.mjml"), - Self::UserImportBlocked => include_str!("../templates/plain-notification.mjml"), + Self::Test => include_str!("templates/test.mjml"), + Self::Welcome => include_str!("templates/enrollment-welcome.mjml"), + Self::SupportData => include_str!("templates/support-data.mjml"), + Self::DesktopStart => include_str!("templates/desktop-start.mjml"), + Self::NewAccount => include_str!("templates/new-account.mjml"), + Self::NewDevice => include_str!("templates/new-device.mjml"), + Self::NewDeviceLogin => include_str!("templates/new-device-login.mjml"), + Self::NewDeviceOIDCLogin => include_str!("templates/new-device-oidc-login.mjml"), + Self::GatewayDisconnect => include_str!("templates/gateway-disconnected.mjml"), + Self::GatewayReconnect => include_str!("templates/gateway-reconnected.mjml"), + Self::MFAActivation => include_str!("templates/mfa-activation.mjml"), + Self::MFAConfigured { method: _ } => include_str!("templates/mfa-configured.mjml"), + Self::MFACode => include_str!("templates/mfa-code.mjml"), + Self::PasswordReset => include_str!("templates/password-reset.mjml"), + Self::PasswordResetDone => include_str!("templates/password-reset-done.mjml"), + Self::UserImportBlocked => include_str!("templates/plain-notification.mjml"), Self::EnrollmentNotification => { - include_str!("../templates/enrollment-admin-notification.mjml") + include_str!("templates/enrollment-admin-notification.mjml") } Self::LetsencryptCertRefreshFailed => { - include_str!("../templates/letsencrypt-cert-refresh-failed.mjml") + include_str!("templates/letsencrypt-cert-refresh-failed.mjml") } Self::CertificateExpiration | Self::CertificateExpired => { - include_str!("../templates/certificate-expiration.mjml") + include_str!("templates/certificate-expiration.mjml") } } } pub(crate) const fn text_template(&self) -> &str { match self { - Self::Test => include_str!("../templates/test.text"), - Self::Welcome => include_str!("../templates/enrollment-welcome.text"), - Self::SupportData => include_str!("../templates/support-data.text"), - Self::DesktopStart => include_str!("../templates/desktop-start.text"), - Self::NewAccount => include_str!("../templates/new-account.text"), - Self::NewDevice => include_str!("../templates/new-device.text"), - Self::NewDeviceLogin => include_str!("../templates/new-device-login.text"), - Self::NewDeviceOIDCLogin => include_str!("../templates/new-device-oidc-login.text"), - Self::GatewayDisconnect => include_str!("../templates/gateway-disconnected.text"), - Self::GatewayReconnect => include_str!("../templates/gateway-reconnected.text"), - Self::MFAActivation => include_str!("../templates/mfa-activation.text"), - Self::MFAConfigured { method: _ } => include_str!("../templates/mfa-configured.text"), - Self::MFACode => include_str!("../templates/mfa-code.text"), - Self::PasswordReset => include_str!("../templates/password-reset.text"), - Self::PasswordResetDone => include_str!("../templates/password-reset-done.text"), - Self::UserImportBlocked => include_str!("../templates/plain-notification.text"), + Self::Test => include_str!("templates/test.text"), + Self::Welcome => include_str!("templates/enrollment-welcome.text"), + Self::SupportData => include_str!("templates/support-data.text"), + Self::DesktopStart => include_str!("templates/desktop-start.text"), + Self::NewAccount => include_str!("templates/new-account.text"), + Self::NewDevice => include_str!("templates/new-device.text"), + Self::NewDeviceLogin => include_str!("templates/new-device-login.text"), + Self::NewDeviceOIDCLogin => include_str!("templates/new-device-oidc-login.text"), + Self::GatewayDisconnect => include_str!("templates/gateway-disconnected.text"), + Self::GatewayReconnect => include_str!("templates/gateway-reconnected.text"), + Self::MFAActivation => include_str!("templates/mfa-activation.text"), + Self::MFAConfigured { method: _ } => include_str!("templates/mfa-configured.text"), + Self::MFACode => include_str!("templates/mfa-code.text"), + Self::PasswordReset => include_str!("templates/password-reset.text"), + Self::PasswordResetDone => include_str!("templates/password-reset-done.text"), + Self::UserImportBlocked => include_str!("templates/plain-notification.text"), Self::EnrollmentNotification => { - include_str!("../templates/enrollment-admin-notification.text") + include_str!("templates/enrollment-admin-notification.text") } Self::LetsencryptCertRefreshFailed => { - include_str!("../templates/letsencrypt-cert-refresh-failed.text") + include_str!("templates/letsencrypt-cert-refresh-failed.text") } Self::CertificateExpiration | Self::CertificateExpired => { - include_str!("../templates/certificate-expiration.text") + include_str!("templates/certificate-expiration.text") } } } diff --git a/crates/defguard_mail/src/qr.rs b/crates/defguard_core/src/mail/qr.rs similarity index 100% rename from crates/defguard_mail/src/qr.rs rename to crates/defguard_core/src/mail/qr.rs diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_core/src/mail/templates.rs similarity index 99% rename from crates/defguard_mail/src/templates.rs rename to crates/defguard_core/src/mail/templates.rs index 68c324fb1e..b38af35baf 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_core/src/mail/templates.rs @@ -15,14 +15,14 @@ use tera::{Context, Function, Tera}; use thiserror::Error; use tracing::{debug, warn}; -use crate::mail::{Attachment, MailMessage}; +use super::{Attachment, MailMessage}; pub(crate) const DEFAULT_LANG: &str = "en_US"; pub static SUPPORT_EMAIL_ADDRESS: &str = "support@defguard.net"; -static BASE_MJML: &str = include_str!("../templates/base.mjml"); -static MACROS_MJML: &str = include_str!("../templates/macros.mjml"); +static BASE_MJML: &str = include_str!("templates/base.mjml"); +static MACROS_MJML: &str = include_str!("templates/macros.mjml"); static MAIL_DATETIME_FORMAT: &str = "%A, %B %d, %Y at %r"; #[derive(Debug, Error)] diff --git a/crates/defguard_mail/templates/base.mjml b/crates/defguard_core/src/mail/templates/base.mjml similarity index 100% rename from crates/defguard_mail/templates/base.mjml rename to crates/defguard_core/src/mail/templates/base.mjml diff --git a/crates/defguard_mail/templates/certificate-expiration.mjml b/crates/defguard_core/src/mail/templates/certificate-expiration.mjml similarity index 100% rename from crates/defguard_mail/templates/certificate-expiration.mjml rename to crates/defguard_core/src/mail/templates/certificate-expiration.mjml diff --git a/crates/defguard_mail/templates/certificate-expiration.text b/crates/defguard_core/src/mail/templates/certificate-expiration.text similarity index 100% rename from crates/defguard_mail/templates/certificate-expiration.text rename to crates/defguard_core/src/mail/templates/certificate-expiration.text diff --git a/crates/defguard_mail/templates/desktop-start.mjml b/crates/defguard_core/src/mail/templates/desktop-start.mjml similarity index 100% rename from crates/defguard_mail/templates/desktop-start.mjml rename to crates/defguard_core/src/mail/templates/desktop-start.mjml diff --git a/crates/defguard_mail/templates/desktop-start.text b/crates/defguard_core/src/mail/templates/desktop-start.text similarity index 100% rename from crates/defguard_mail/templates/desktop-start.text rename to crates/defguard_core/src/mail/templates/desktop-start.text diff --git a/crates/defguard_mail/templates/enrollment-admin-notification.mjml b/crates/defguard_core/src/mail/templates/enrollment-admin-notification.mjml similarity index 100% rename from crates/defguard_mail/templates/enrollment-admin-notification.mjml rename to crates/defguard_core/src/mail/templates/enrollment-admin-notification.mjml diff --git a/crates/defguard_mail/templates/enrollment-admin-notification.text b/crates/defguard_core/src/mail/templates/enrollment-admin-notification.text similarity index 100% rename from crates/defguard_mail/templates/enrollment-admin-notification.text rename to crates/defguard_core/src/mail/templates/enrollment-admin-notification.text diff --git a/crates/defguard_mail/templates/enrollment-welcome.mjml b/crates/defguard_core/src/mail/templates/enrollment-welcome.mjml similarity index 100% rename from crates/defguard_mail/templates/enrollment-welcome.mjml rename to crates/defguard_core/src/mail/templates/enrollment-welcome.mjml diff --git a/crates/defguard_mail/templates/enrollment-welcome.text b/crates/defguard_core/src/mail/templates/enrollment-welcome.text similarity index 100% rename from crates/defguard_mail/templates/enrollment-welcome.text rename to crates/defguard_core/src/mail/templates/enrollment-welcome.text diff --git a/crates/defguard_mail/templates/gateway-disconnected.mjml b/crates/defguard_core/src/mail/templates/gateway-disconnected.mjml similarity index 100% rename from crates/defguard_mail/templates/gateway-disconnected.mjml rename to crates/defguard_core/src/mail/templates/gateway-disconnected.mjml diff --git a/crates/defguard_mail/templates/gateway-disconnected.text b/crates/defguard_core/src/mail/templates/gateway-disconnected.text similarity index 100% rename from crates/defguard_mail/templates/gateway-disconnected.text rename to crates/defguard_core/src/mail/templates/gateway-disconnected.text diff --git a/crates/defguard_mail/templates/gateway-reconnected.mjml b/crates/defguard_core/src/mail/templates/gateway-reconnected.mjml similarity index 100% rename from crates/defguard_mail/templates/gateway-reconnected.mjml rename to crates/defguard_core/src/mail/templates/gateway-reconnected.mjml diff --git a/crates/defguard_mail/templates/gateway-reconnected.text b/crates/defguard_core/src/mail/templates/gateway-reconnected.text similarity index 100% rename from crates/defguard_mail/templates/gateway-reconnected.text rename to crates/defguard_core/src/mail/templates/gateway-reconnected.text diff --git a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml b/crates/defguard_core/src/mail/templates/letsencrypt-cert-refresh-failed.mjml similarity index 100% rename from crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml rename to crates/defguard_core/src/mail/templates/letsencrypt-cert-refresh-failed.mjml diff --git a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text b/crates/defguard_core/src/mail/templates/letsencrypt-cert-refresh-failed.text similarity index 100% rename from crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text rename to crates/defguard_core/src/mail/templates/letsencrypt-cert-refresh-failed.text diff --git a/crates/defguard_mail/templates/macros.mjml b/crates/defguard_core/src/mail/templates/macros.mjml similarity index 100% rename from crates/defguard_mail/templates/macros.mjml rename to crates/defguard_core/src/mail/templates/macros.mjml diff --git a/crates/defguard_mail/templates/mfa-activation.mjml b/crates/defguard_core/src/mail/templates/mfa-activation.mjml similarity index 100% rename from crates/defguard_mail/templates/mfa-activation.mjml rename to crates/defguard_core/src/mail/templates/mfa-activation.mjml diff --git a/crates/defguard_mail/templates/mfa-activation.text b/crates/defguard_core/src/mail/templates/mfa-activation.text similarity index 100% rename from crates/defguard_mail/templates/mfa-activation.text rename to crates/defguard_core/src/mail/templates/mfa-activation.text diff --git a/crates/defguard_mail/templates/mfa-code.mjml b/crates/defguard_core/src/mail/templates/mfa-code.mjml similarity index 100% rename from crates/defguard_mail/templates/mfa-code.mjml rename to crates/defguard_core/src/mail/templates/mfa-code.mjml diff --git a/crates/defguard_mail/templates/mfa-code.text b/crates/defguard_core/src/mail/templates/mfa-code.text similarity index 100% rename from crates/defguard_mail/templates/mfa-code.text rename to crates/defguard_core/src/mail/templates/mfa-code.text diff --git a/crates/defguard_mail/templates/mfa-configured.mjml b/crates/defguard_core/src/mail/templates/mfa-configured.mjml similarity index 100% rename from crates/defguard_mail/templates/mfa-configured.mjml rename to crates/defguard_core/src/mail/templates/mfa-configured.mjml diff --git a/crates/defguard_mail/templates/mfa-configured.text b/crates/defguard_core/src/mail/templates/mfa-configured.text similarity index 100% rename from crates/defguard_mail/templates/mfa-configured.text rename to crates/defguard_core/src/mail/templates/mfa-configured.text diff --git a/crates/defguard_mail/templates/new-account.mjml b/crates/defguard_core/src/mail/templates/new-account.mjml similarity index 100% rename from crates/defguard_mail/templates/new-account.mjml rename to crates/defguard_core/src/mail/templates/new-account.mjml diff --git a/crates/defguard_mail/templates/new-account.text b/crates/defguard_core/src/mail/templates/new-account.text similarity index 100% rename from crates/defguard_mail/templates/new-account.text rename to crates/defguard_core/src/mail/templates/new-account.text diff --git a/crates/defguard_mail/templates/new-device-login.mjml b/crates/defguard_core/src/mail/templates/new-device-login.mjml similarity index 100% rename from crates/defguard_mail/templates/new-device-login.mjml rename to crates/defguard_core/src/mail/templates/new-device-login.mjml diff --git a/crates/defguard_mail/templates/new-device-login.text b/crates/defguard_core/src/mail/templates/new-device-login.text similarity index 100% rename from crates/defguard_mail/templates/new-device-login.text rename to crates/defguard_core/src/mail/templates/new-device-login.text diff --git a/crates/defguard_mail/templates/new-device-oidc-login.mjml b/crates/defguard_core/src/mail/templates/new-device-oidc-login.mjml similarity index 100% rename from crates/defguard_mail/templates/new-device-oidc-login.mjml rename to crates/defguard_core/src/mail/templates/new-device-oidc-login.mjml diff --git a/crates/defguard_mail/templates/new-device-oidc-login.text b/crates/defguard_core/src/mail/templates/new-device-oidc-login.text similarity index 100% rename from crates/defguard_mail/templates/new-device-oidc-login.text rename to crates/defguard_core/src/mail/templates/new-device-oidc-login.text diff --git a/crates/defguard_mail/templates/new-device.mjml b/crates/defguard_core/src/mail/templates/new-device.mjml similarity index 100% rename from crates/defguard_mail/templates/new-device.mjml rename to crates/defguard_core/src/mail/templates/new-device.mjml diff --git a/crates/defguard_mail/templates/new-device.text b/crates/defguard_core/src/mail/templates/new-device.text similarity index 100% rename from crates/defguard_mail/templates/new-device.text rename to crates/defguard_core/src/mail/templates/new-device.text diff --git a/crates/defguard_mail/templates/password-reset-done.mjml b/crates/defguard_core/src/mail/templates/password-reset-done.mjml similarity index 100% rename from crates/defguard_mail/templates/password-reset-done.mjml rename to crates/defguard_core/src/mail/templates/password-reset-done.mjml diff --git a/crates/defguard_mail/templates/password-reset-done.text b/crates/defguard_core/src/mail/templates/password-reset-done.text similarity index 100% rename from crates/defguard_mail/templates/password-reset-done.text rename to crates/defguard_core/src/mail/templates/password-reset-done.text diff --git a/crates/defguard_mail/templates/password-reset.mjml b/crates/defguard_core/src/mail/templates/password-reset.mjml similarity index 100% rename from crates/defguard_mail/templates/password-reset.mjml rename to crates/defguard_core/src/mail/templates/password-reset.mjml diff --git a/crates/defguard_mail/templates/password-reset.text b/crates/defguard_core/src/mail/templates/password-reset.text similarity index 100% rename from crates/defguard_mail/templates/password-reset.text rename to crates/defguard_core/src/mail/templates/password-reset.text diff --git a/crates/defguard_mail/templates/plain-notification.mjml b/crates/defguard_core/src/mail/templates/plain-notification.mjml similarity index 100% rename from crates/defguard_mail/templates/plain-notification.mjml rename to crates/defguard_core/src/mail/templates/plain-notification.mjml diff --git a/crates/defguard_mail/templates/plain-notification.text b/crates/defguard_core/src/mail/templates/plain-notification.text similarity index 100% rename from crates/defguard_mail/templates/plain-notification.text rename to crates/defguard_core/src/mail/templates/plain-notification.text diff --git a/crates/defguard_mail/templates/support-data.mjml b/crates/defguard_core/src/mail/templates/support-data.mjml similarity index 100% rename from crates/defguard_mail/templates/support-data.mjml rename to crates/defguard_core/src/mail/templates/support-data.mjml diff --git a/crates/defguard_mail/templates/support-data.text b/crates/defguard_core/src/mail/templates/support-data.text similarity index 100% rename from crates/defguard_mail/templates/support-data.text rename to crates/defguard_core/src/mail/templates/support-data.text diff --git a/crates/defguard_mail/templates/test.mjml b/crates/defguard_core/src/mail/templates/test.mjml similarity index 100% rename from crates/defguard_mail/templates/test.mjml rename to crates/defguard_core/src/mail/templates/test.mjml diff --git a/crates/defguard_mail/templates/test.text b/crates/defguard_core/src/mail/templates/test.text similarity index 100% rename from crates/defguard_mail/templates/test.text rename to crates/defguard_core/src/mail/templates/test.text diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_core/src/mail/tests.rs similarity index 99% rename from crates/defguard_mail/src/tests.rs rename to crates/defguard_core/src/mail/tests.rs index 7eadb4dc9f..2c5dc62142 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_core/src/mail/tests.rs @@ -23,10 +23,7 @@ use sqlx::{ use tera::Context; use tokio::time::sleep; -use super::{ - mail::{Attachment, MailMessage}, - templates, -}; +use super::{Attachment, MailMessage, templates}; #[test] fn dg25_8_server_side_template_injection() { @@ -444,7 +441,7 @@ fn send_certificate_expired(_: PgPoolOptions, options: PgConnectOptions) { } mod markdown_to_html { - use crate::templates::markdown_to_html; + use super::templates::markdown_to_html; fn has_tag(html: &str, tag: &str) -> bool { html.contains(&format!("<{tag}")) diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index 2870541a79..c093336567 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -8,7 +8,6 @@ use defguard_common::{ }, types::proxy::ProxyControlMessage, }; -use defguard_mail::templates; use sqlx::{PgConnection, PgPool, query_as}; use tokio::{ sync::{broadcast, mpsc}, @@ -30,6 +29,7 @@ use crate::{ grpc::GatewayCommand, letsencrypt::do_letsencrypt_refresh, location_management::allowed_peers::get_location_allowed_peers, + mail::templates, updates::do_new_version_check, }; diff --git a/crates/defguard_core/tests/integration/api/openid_login.rs b/crates/defguard_core/tests/integration/api/openid_login.rs index 7788503681..1ea13fe2be 100644 --- a/crates/defguard_core/tests/integration/api/openid_login.rs +++ b/crates/defguard_core/tests/integration/api/openid_login.rs @@ -60,6 +60,7 @@ async fn test_openid_providers(_: PgPoolOptions, options: PgConnectOptions) { username_handling: OpenIdUsernameHandling::PruneEmailDomain, jumpcloud_api_key: None, prefetch_users: false, + directory_sync_user_groups: None, }; let response = client @@ -168,6 +169,7 @@ async fn test_openid_login(_: PgPoolOptions, options: PgConnectOptions) { username_handling: OpenIdUsernameHandling::PruneEmailDomain, jumpcloud_api_key: None, prefetch_users: false, + directory_sync_user_groups: None, }; let response = client .post("/api/v1/openid/provider") diff --git a/crates/defguard_core/tests/integration/api/wireguard.rs b/crates/defguard_core/tests/integration/api/wireguard.rs index 7e2b0192b9..2ebf5435fc 100644 --- a/crates/defguard_core/tests/integration/api/wireguard.rs +++ b/crates/defguard_core/tests/integration/api/wireguard.rs @@ -419,6 +419,7 @@ async fn test_location_mfa_mode_validation_create(_: PgPoolOptions, options: PgC username_handling: OpenIdUsernameHandling::PruneEmailDomain, jumpcloud_api_key: None, prefetch_users: false, + directory_sync_user_groups: None, }; let response = client @@ -521,6 +522,7 @@ async fn test_location_mfa_mode_validation_modify(_: PgPoolOptions, options: PgC username_handling: OpenIdUsernameHandling::PruneEmailDomain, jumpcloud_api_key: None, prefetch_users: false, + directory_sync_user_groups: None, }; let response = client diff --git a/crates/defguard_event_logger/src/tests/mod.rs b/crates/defguard_event_logger/src/tests/mod.rs index 9a05e5e47c..48b0882172 100644 --- a/crates/defguard_event_logger/src/tests/mod.rs +++ b/crates/defguard_event_logger/src/tests/mod.rs @@ -308,6 +308,7 @@ fn api_event_cases() -> Vec { directory_sync_group_match: Vec::new(), jumpcloud_api_key: None, prefetch_users: false, + directory_sync_user_groups: None, }; let log_stream = ActivityLogStream { id: 1, diff --git a/crates/defguard_mail/Cargo.toml b/crates/defguard_mail/Cargo.toml deleted file mode 100644 index 4cf337077e..0000000000 --- a/crates/defguard_mail/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "defguard_mail" -version = "0.0.0" -edition.workspace = true -license-file.workspace = true -homepage.workspace = true -repository.workspace = true -rust-version.workspace = true - -[dependencies] -defguard_common.workspace = true - -chrono.workspace = true -lettre.workspace = true -openidconnect.workspace = true -pulldown-cmark.workspace = true -reqwest.workspace = true -serde.workspace = true -serde_json.workspace = true -sqlx.workspace = true -tera.workspace = true -thiserror.workspace = true -tokio.workspace = true -tracing.workspace = true -humantime.workspace = true - -css-inline = "0.20" -image = "0.25" # match with qrforge -mrml = "6.0" -qrforge = {version = "0.1", default-features = false, features = ["image"]} - -[dev-dependencies] -claims.workspace = true diff --git a/crates/defguard_mail/src/lib.rs b/crates/defguard_mail/src/lib.rs deleted file mode 100644 index 2dc9e329e0..0000000000 --- a/crates/defguard_mail/src/lib.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Handle email messages. -//! -//! Refer to: -//! - [RFC 2557](https://datatracker.ietf.org/doc/html/rfc2557) -//! - [Meaning of mulitpart](https://www.codestudy.net/blog/mail-multipart-alternative-vs-multipart-mixed/) - -pub mod mail; -pub(crate) mod mail_context; -mod qr; -pub mod templates; -#[cfg(test)] -mod tests; -mod xoauth2; - -#[derive(Debug, thiserror::Error)] -pub enum MailError { - #[error(transparent)] - LettreError(#[from] lettre::error::Error), - - #[error(transparent)] - AddressError(#[from] lettre::address::AddressError), - - #[error(transparent)] - SmtpError(#[from] lettre::transport::smtp::Error), - - #[error(transparent)] - SqlxError(#[from] sqlx::Error), - - #[error("SMTP not configured")] - SmtpNotConfigured, - - #[error("Invalid port: {0}")] - InvalidPort(i32), - - #[error(transparent)] - ReqwestError(#[from] openidconnect::reqwest::Error), - - #[error(transparent)] - UrlError(#[from] openidconnect::url::ParseError), - - #[error(transparent)] - OAuth2Error(#[from] openidconnect::ConfigurationError), - - #[error("Open ID discovery")] - OpenIDDiscovery, - - #[error("Refresh token exchange")] - RefreshTokenExchange, -} diff --git a/crates/defguard_mail/src/xoauth2.rs b/crates/defguard_mail/src/xoauth2.rs deleted file mode 100644 index b9b7a943a6..0000000000 --- a/crates/defguard_mail/src/xoauth2.rs +++ /dev/null @@ -1,64 +0,0 @@ -use defguard_common::db::models::settings::smtp::SmtpSettings; -use openidconnect::{ - ClientId, ClientSecret, IssuerUrl, OAuth2TokenResponse, RefreshToken, - core::{CoreClient, CoreProviderMetadata}, - reqwest::{ClientBuilder, redirect::Policy}, -}; -use tracing::{debug, error}; - -use super::MailError; - -/// Obtain access token for XOAUTH2 authentication. -pub(super) async fn obtain_access_token( - smtp_settings: &mut SmtpSettings, -) -> Result { - let (Some(issuer_url), Some(client_id), Some(client_secret), Some(refresh_token)) = ( - &smtp_settings.oauth_issuer_url, - &smtp_settings.oauth_client_id, - &smtp_settings.oauth_client_secret, - &smtp_settings.oauth_refresh_token, - ) else { - error!("SMTP XOAUTH requires: issuer URL, client ID, client secret, and refresh token"); - return Err(MailError::SmtpNotConfigured); - }; - let issuer_url = IssuerUrl::new(issuer_url.into())?; - let client_id = ClientId::new(client_id.into()); - let client_secret = ClientSecret::new(client_secret.expose_secret().into()); - let refresh_token = RefreshToken::new(refresh_token.into()); - - let http_client = ClientBuilder::new() - // Following redirects opens the client up to SSRF vulnerabilities. - .redirect(Policy::none()) - .build()?; - - let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, &http_client) - .await - .map_err(|err| { - error!("Failed OpenID Connect Discovery: {err}"); - MailError::OpenIDDiscovery - })?; - - let client = - CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); - - let token_response = client - .exchange_refresh_token(&refresh_token)? - .request_async(&http_client) - .await - .map_err(|err| { - error!("Failed to fetch token: {err}"); - MailError::RefreshTokenExchange - })?; - - let access_token = token_response.access_token().secret(); - debug!("Got access token"); - if let Some(expires_in) = token_response.expires_in() { - debug!("Access token expires in:\n{expires_in:?}\n"); - } - if let Some(refresh_token) = token_response.refresh_token() { - debug!("Got refresh token"); - // TODO: use `smtp_settings.set_oauth_refresh_token` - smtp_settings.oauth_refresh_token = Some(refresh_token.secret().into()); - } - Ok(access_token.clone()) -} diff --git a/crates/defguard_proxy_manager/Cargo.toml b/crates/defguard_proxy_manager/Cargo.toml index ba61a76275..fec3973898 100644 --- a/crates/defguard_proxy_manager/Cargo.toml +++ b/crates/defguard_proxy_manager/Cargo.toml @@ -13,7 +13,6 @@ defguard_certs.workspace = true defguard_common.workspace = true defguard_core.workspace = true defguard_grpc_tls.workspace = true -defguard_mail.workspace = true defguard_proto.workspace = true defguard_version.workspace = true diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index de53bc8468..689363ff77 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -34,10 +34,10 @@ use defguard_core::{ handlers::user::check_password_strength, headers::get_device_info, is_valid_phone_number, -}; -use defguard_mail::templates::{ - TemplateLocation, enrollment_admin_notification, mfa_activation_mail, mfa_configured_mail, - new_device_added_mail, + mail::templates::{ + TemplateLocation, enrollment_admin_notification, mfa_activation_mail, mfa_configured_mail, + new_device_added_mail, + }, }; use defguard_proto::client_types::{ ActivateUserRequest, AdminInfo, CodeMfaSetupFinishRequest, CodeMfaSetupFinishResponse, @@ -428,9 +428,9 @@ impl EnrollmentServer { "Fetching user {} data to check if the user already has a password.", user.username ); - if user.has_password() { - error!("User {} already activated", user.username); - return Err(Status::invalid_argument("user already activated")); + if user.is_enrolled() { + error!("User {} already enrolled", user.username); + return Err(Status::invalid_argument("user already enrolled")); } debug!("User doesn't have a password yet. Continue user activation process..."); diff --git a/crates/defguard_proxy_manager/src/servers/password_reset.rs b/crates/defguard_proxy_manager/src/servers/password_reset.rs index a1f942e711..3e0c252f64 100644 --- a/crates/defguard_proxy_manager/src/servers/password_reset.rs +++ b/crates/defguard_proxy_manager/src/servers/password_reset.rs @@ -11,8 +11,8 @@ use defguard_core::{ grpc::utils::parse_client_ip_agent, handlers::user::check_password_strength, headers::get_device_info, + mail::templates::{password_reset_mail, password_reset_success_mail}, }; -use defguard_mail::templates::{password_reset_mail, password_reset_success_mail}; use defguard_proto::proxy::{ DeviceInfo, PasswordResetInitializeRequest, PasswordResetRequest, PasswordResetStartRequest, PasswordResetStartResponse, diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs index 4b04d3ac24..ce7d90d6e9 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs @@ -248,6 +248,80 @@ async fn test_activate_user_already_activated_returns_error( context.finish().await.expect_server_finished().await; } +/// Regression: a user with a password whose enrollment was retriggered +/// (`enrollment_pending == true`) must be able to enroll again, because the +/// activation gate checks `is_enrolled()` rather than `has_password()`. +#[sqlx::test] +async fn test_activate_user_with_password_but_enrollment_pending_can_reenroll( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let mut context = HandlerTestContext::new(options).await; + complete_proxy_handshake(&mut context).await; + + let mut user = create_user(&context.pool).await; + user.set_password("OldPassw0rd!"); + user.enrollment_pending = true; + user.save(&context.pool) + .await + .expect("failed to save user with pending enrollment"); + assert!(!user.is_enrolled()); + + let token = create_enrollment_token(&context.pool, user.id, Some(user.id)).await; + start_enrollment_session(&mut context, &token.id).await; + let _ = timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()).await; + + let response = send_activate_user(&mut context, &token.id, STRONG_PASSWORD, None).await; + match &response.payload { + Some(core_response::Payload::Empty(())) => {} + other => panic!( + "expected Empty on re-enrollment of pending user, got: {:?}", + other.as_ref().map(std::mem::discriminant) + ), + } + + let updated = User::find_by_username(&context.pool, &user.username) + .await + .expect("db query failed") + .expect("user not found"); + assert!(!updated.enrollment_pending); + assert!(updated.is_enrolled()); + + context.finish().await.expect_server_finished().await; +} + +/// Regression: a user who is already enrolled (password set, not +/// enrollment-pending) must be rejected with `InvalidArgument` when enrolling. +#[sqlx::test] +async fn test_activate_user_already_enrolled_cannot_reenroll( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let mut context = HandlerTestContext::new(options).await; + complete_proxy_handshake(&mut context).await; + + let mut user = create_user(&context.pool).await; + user.set_password("OldPassw0rd!"); + user.enrollment_pending = false; + user.save(&context.pool) + .await + .expect("failed to save already-enrolled user"); + assert!(user.is_enrolled()); + + let token = create_enrollment_token(&context.pool, user.id, Some(user.id)).await; + start_enrollment_session(&mut context, &token.id).await; + + let response = send_activate_user(&mut context, &token.id, STRONG_PASSWORD, None).await; + let code = assert_error_response(&response); + assert_eq!( + code, + tonic::Code::InvalidArgument, + "activating an already-enrolled user must return InvalidArgument" + ); + + context.finish().await.expect_server_finished().await; +} + #[sqlx::test] async fn test_new_device_sends_gateway_device_created_event( _: PgPoolOptions, diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs index b45fc5db23..8c25972455 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs @@ -746,6 +746,7 @@ pub(crate) async fn create_oidc_provider( directory_sync_group_match: Vec::new(), jumpcloud_api_key: None, prefetch_users: false, + directory_sync_user_groups: None, } .save(pool) .await diff --git a/crates/model_derive/src/lib.rs b/crates/model_derive/src/lib.rs index ca10d10164..46f4122f7e 100644 --- a/crates/model_derive/src/lib.rs +++ b/crates/model_derive/src/lib.rs @@ -10,6 +10,7 @@ enum ModelType { Enum, Ip, Option, + OptionRef, Ref, Secret, } @@ -31,6 +32,8 @@ fn model_attr(field: &Field) -> syn::Result> { ModelType::Ip } else if meta.path.is_ident("option") { ModelType::Option + } else if meta.path.is_ident("option_ref") { + ModelType::OptionRef } else if meta.path.is_ident("ref") { ModelType::Ref } else if meta.path.is_ident("secret") { @@ -174,7 +177,7 @@ pub fn derive(input: TokenStream) -> TokenStream { match model_type { ModelType::Secret => cs_aliased_fields.push_str("?: SecretString\""), ModelType::Ip => cs_aliased_fields.push_str(": IpAddr\""), - ModelType::Option => cs_aliased_fields.push_str("?: _\""), + ModelType::Option | ModelType::OptionRef => cs_aliased_fields.push_str("?: _\""), ModelType::Enum | ModelType::Ref => cs_aliased_fields.push_str(": _\""), } } @@ -200,6 +203,7 @@ pub fn derive(input: TokenStream) -> TokenStream { quote! { &self.#name } } } + Some(ModelType::OptionRef) => quote! { self.#name.as_deref() }, Some(ModelType::Secret) => { // FIXME: hard-coded struct name quote! { &self.#name as &Option } diff --git a/deny.toml b/deny.toml index fed44dcbeb..0a80f20b85 100644 --- a/deny.toml +++ b/deny.toml @@ -124,10 +124,6 @@ exceptions = [ "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_core" }, - { allow = [ - "AGPL-3.0-only", - "AGPL-3.0-or-later", - ], crate = "defguard_mail" }, { allow = [ "AGPL-3.0-only", "AGPL-3.0-or-later", diff --git a/docker-compose.ldap-test.yaml b/docker-compose.ldap-test.yaml index 42b6a5fb5a..0c5f55527d 100644 --- a/docker-compose.ldap-test.yaml +++ b/docker-compose.ldap-test.yaml @@ -17,7 +17,7 @@ services: retries: 5 openldap: - image: public.ecr.aws/bitnamilegacy/openldap:2.6 + image: ghcr.io/defguard/openldap:2.6.8 user: root environment: LDAP_ADMIN_PASSWORD: "pass123" diff --git a/docs/cover-image_smaller-logo.png b/docs/cover-image_smaller-logo.png new file mode 100644 index 0000000000..83ee1e2261 Binary files /dev/null and b/docs/cover-image_smaller-logo.png differ diff --git a/docs/new_defguard-architecture.png b/docs/new_defguard-architecture.png new file mode 100644 index 0000000000..69a3352c63 Binary files /dev/null and b/docs/new_defguard-architecture.png differ diff --git a/e2e/utils/controllers/mfa/enableEmail.ts b/e2e/utils/controllers/mfa/enableEmail.ts index db6b14f4b9..6fc024d9e2 100644 --- a/e2e/utils/controllers/mfa/enableEmail.ts +++ b/e2e/utils/controllers/mfa/enableEmail.ts @@ -20,13 +20,14 @@ export const setupSMTP = async (browser: Browser) => { await waitForBase(page); await loginBasic(page, defaultUserAdmin); await page.goto(routes.base + routes.settings.smtp); + await page.getByTestId('smtp-card-basic-configure').click(); await page.getByTestId('field-smtp_server').waitFor({ state: 'visible' }); await page.getByTestId('field-smtp_server').fill('testServer.com'); await page.getByTestId('field-smtp_port').fill('543'); await page.getByTestId('field-smtp_user').fill('testuser'); await page.getByTestId('field-smtp_password').fill('test'); await page.getByTestId('field-smtp_sender').fill('test@test.com'); - const saveButton = await page.getByTestId('save-changes'); + const saveButton = await page.getByTestId('submit'); if (await saveButton.isEnabled()) { await saveButton.click(); } diff --git a/freebsd/post-install.sh b/freebsd/post-install.sh new file mode 100755 index 0000000000..38fe967f1b --- /dev/null +++ b/freebsd/post-install.sh @@ -0,0 +1,6 @@ +#!/bin/sh +CONFIG=/etc/defguard/core.conf + +if [ ! -f "${CONFIG}" ]; then + cp "${CONFIG}.sample" "${CONFIG}" +fi diff --git a/migrations/20260601090255_[2.0.2]_smtp_oauth_tenant_id.down.sql b/migrations/20260601090255_[2.0.2]_smtp_oauth_tenant_id.down.sql new file mode 100644 index 0000000000..b8922ea8b2 --- /dev/null +++ b/migrations/20260601090255_[2.0.2]_smtp_oauth_tenant_id.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE settings + DROP COLUMN smtp_oauth_tenant_id; diff --git a/migrations/20260601090255_[2.0.2]_smtp_oauth_tenant_id.up.sql b/migrations/20260601090255_[2.0.2]_smtp_oauth_tenant_id.up.sql new file mode 100644 index 0000000000..0decb10ef3 --- /dev/null +++ b/migrations/20260601090255_[2.0.2]_smtp_oauth_tenant_id.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE settings + ADD COLUMN smtp_oauth_tenant_id text NULL; diff --git a/migrations/20260604105122_[2.0.0]_directory_sync_user_groups.down.sql b/migrations/20260604105122_[2.0.0]_directory_sync_user_groups.down.sql new file mode 100644 index 0000000000..9cf615e144 --- /dev/null +++ b/migrations/20260604105122_[2.0.0]_directory_sync_user_groups.down.sql @@ -0,0 +1 @@ +ALTER TABLE openidprovider DROP COLUMN directory_sync_user_groups; diff --git a/migrations/20260604105122_[2.0.0]_directory_sync_user_groups.up.sql b/migrations/20260604105122_[2.0.0]_directory_sync_user_groups.up.sql new file mode 100644 index 0000000000..a02ab33801 --- /dev/null +++ b/migrations/20260604105122_[2.0.0]_directory_sync_user_groups.up.sql @@ -0,0 +1 @@ +ALTER TABLE openidprovider ADD COLUMN directory_sync_user_groups TEXT[]; diff --git a/web/messages/en/api-error.json b/web/messages/en/api-error.json index 9c343383c7..af273eaf4a 100644 --- a/web/messages/en/api-error.json +++ b/web/messages/en/api-error.json @@ -1,4 +1,5 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", - "api_error_network_full": "There are no free IP adresses in the network - please change the network settings" + "api_error_network_full": "There are no free IP adresses in the network - please change the network settings", + "api_error_user_groups_not_synced": "You could not be logged in because your group is not synchronized, please contact your administrator" } diff --git a/web/messages/en/common.json b/web/messages/en/common.json index 811cabefb0..41df4c62c6 100644 --- a/web/messages/en/common.json +++ b/web/messages/en/common.json @@ -87,5 +87,6 @@ "failed_to_start_enrollment": "Failed to start enrollment", "misc_recommended": "Recommended", "footer_copyright": "Copyright © {year} Defguard Sp. z o.o.", - "error_unknown": "An unknown error occurred" + "error_unknown": "An unknown error occurred", + "controls_configure": "Configure" } diff --git a/web/messages/en/openid.json b/web/messages/en/openid.json index 6f5b502b9b..e7b82e5edc 100644 --- a/web/messages/en/openid.json +++ b/web/messages/en/openid.json @@ -46,8 +46,10 @@ "settings_openid_provider_google_service_account_key_title": "Service account key", "settings_openid_provider_google_service_account_key_content": "Upload a new service account key file to set the service account used for synchronization. NOTE: The uploaded file won't be visible after saving the settings and reloading the page because its contents are sensitive and are never sent back to the dashboard.", "settings_openid_provider_label_sync_only_matching_groups": "Sync only matching memberships", - "settings_openid_provider_helper_microsoft_group_match": "To synchronize specific group memberships, provide the group names in the input field below. All other group memberships will be ignored.", + "settings_openid_provider_helper_microsoft_group_match": "Groups Defguard should sync memberships for. Leave it empty to sync every group. If you fill it in, only those groups are kept and the rest are ignored. This only affects how group memberships are mapped, not who gets an account. Separate group names with commas.", "settings_openid_provider_prefetch_users": "Import users from your Microsoft directory to Defguard", + "settings_openid_provider_label_sync_users_from_groups": "Synchronize users only from specified groups", + "settings_openid_provider_helper_sync_users_from_groups": "Only people who belong to these groups will have account created in Defguard. Leave it empty to allow all users. If you also set \"Sync only matching memberships\" above, list the same groups there too, otherwise their members won't be recognized. Separate group names with commas.", "settings_openid_provider_label_okta_directory_sync_client_id": "Directory sync client ID", "settings_openid_provider_helper_okta_directory_sync_client_id": "", "settings_openid_provider_label_okta_directory_sync_client_private_key": "Directory sync client private key", @@ -59,7 +61,7 @@ "settings_openid_provider_create_account_title": "Automatically create an account on first sign-in with this external identity provider.", "settings_openid_provider_create_account_content": "If enabled, Defguard automatically creates an account when a user signs in for the first time with this external identity provider. Otherwise, the account must be created by an administrator first.", "settings_openid_provider_edit_title": "Edit external identity provider", - "settings_openid_provider_delete_button": "Delete external identity provider", + "settings_openid_provider_delete_button": "Delete", "settings_openid_provider_delete_confirm_title": "Delete external identity provider", "settings_openid_provider_delete_confirm_body": "Are you sure you want to delete this external identity provider? This action cannot be undone.", "settings_openid_provider_delete_success": "External identity provider deleted", diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index b829f66dfd..855c5b0f1c 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -332,5 +332,39 @@ "settings_gateway_notifications_reconnect_title": "Gateway reconnect notifications", "settings_gateway_notifications_reconnect_content": "Send an email notification to admin users when a gateway reconnects.", "settings_gateway_notifications_inactivity_threshold_label": "Inactivity threshold for disconnect notifications (minutes)", - "settings_gateway_notifications_inactivity_threshold_helper": "" + "settings_gateway_notifications_inactivity_threshold_helper": "", + "settings_smtp_activate_confirm_body": "Activating a new SMTP method will automatically disable the currently active configuration. Do you want to continue?", + "settings_smtp_activate_confirm_title": "Activate new SMTP method", + "settings_smtp_active_config_label": "Active SMTP configuration", + "settings_smtp_auth_card_active_method": "Applied", + "settings_smtp_auth_card_basic_description": "Authenticate with a username and password for secure email delivery.", + "settings_smtp_auth_card_basic_name": "Username and password", + "settings_smtp_auth_card_custom_description": "Authenticate SMTP using your organization’s custom provider.", + "settings_smtp_auth_card_custom_name": "Custom OAuth2 provider", + "settings_smtp_auth_card_google_description": "Connect to Gmail using a Google Client ID and Client Secret.", + "settings_smtp_auth_card_google_name": "Google", + "settings_smtp_auth_card_microsoft_description": "Connect to Microsoft 365 or Outlook using a Client ID and Client Secret.", + "settings_smtp_auth_card_microsoft_name": "Microsoft", + "settings_smtp_auth_card_none_description": "Connect using only the server address, port, encryption type, and sender email.", + "settings_smtp_auth_card_none_name": "No Authentication", + "settings_smtp_auth_modal_basic_title": "SMTP method with username and password", + "settings_smtp_auth_modal_custom_title": "SMTP method with custom OAuth2 authentication", + "settings_smtp_auth_modal_google_title": "SMTP method with Google OAuth2", + "settings_smtp_auth_modal_microsoft_title": "SMTP method with Microsoft OAuth2", + "settings_smtp_auth_modal_none_title": "SMTP method without authentication", + "settings_smtp_auth_oauth_error": "Authorization failed", + "settings_smtp_auth_oauth_info": "Clicking Apply will open a browser window to authorize access. You will be asked to sign in and grant permission. Make sure /smtp-oauth-callback is authorized as a redirect URL.", + "settings_smtp_auth_oauth_popup_blocked": "Authorization popup was blocked. Please allow popups for this site.", + "settings_smtp_auth_oauth_popup_closed": "Authorization popup was closed before completing.", + "settings_smtp_helper_oauth_client_id": "", + "settings_smtp_helper_oauth_client_secret": "", + "settings_smtp_helper_oauth_issuer_url": "", + "settings_smtp_helper_oauth_scope": "OAuth2 scopes to request, space-separated.", + "settings_smtp_helper_oauth_tenant_id": "", + "settings_smtp_label_oauth_client_id": "Client ID", + "settings_smtp_label_oauth_client_secret": "Client Secret", + "settings_smtp_label_oauth_issuer_url": "Issuer URL", + "settings_smtp_label_oauth_scope": "Scope", + "settings_smtp_label_oauth_tenant_id": "Tenant ID", + "settings_smtp_other_methods_label": "Other configuration methods" } diff --git a/web/public/smtp-oauth-relay.worker.js b/web/public/smtp-oauth-relay.worker.js new file mode 100644 index 0000000000..fc420e7ade --- /dev/null +++ b/web/public/smtp-oauth-relay.worker.js @@ -0,0 +1,19 @@ +// SharedWorker relay for SMTP OAuth code delivery. +// Relays messages between the parent window and the OAuth callback popup +// when COOP has severed window.opener (e.g. accounts.google.com sets +// Cross-Origin-Opener-Policy: same-origin). SharedWorkers cross browsing-context- +// group boundaries, unlike postMessage and BroadcastChannel. +const ports = new Set(); + +self.addEventListener('connect', (e) => { + const port = e.ports[0]; + ports.add(port); + + port.addEventListener('message', (msg) => { + for (const p of ports) { + if (p !== port) p.postMessage(msg.data); + } + }); + + port.start(); +}); diff --git a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/MicrosoftProviderForm.tsx b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/MicrosoftProviderForm.tsx index 6efd1f3f11..c23bd1497e 100644 --- a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/MicrosoftProviderForm.tsx +++ b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/MicrosoftProviderForm.tsx @@ -2,15 +2,10 @@ import { useMutation } from '@tanstack/react-query'; import { useMemo } from 'react'; import type z from 'zod'; import { m } from '../../../../../paraglide/messages'; -import { AppText } from '../../../../../shared/defguard-ui/components/AppText/AppText'; import { Divider } from '../../../../../shared/defguard-ui/components/Divider/Divider'; import { EvenSplit } from '../../../../../shared/defguard-ui/components/EvenSplit/EvenSplit'; import { SizedBox } from '../../../../../shared/defguard-ui/components/SizedBox/SizedBox'; -import { - TextStyle, - ThemeSpacing, - ThemeVariable, -} from '../../../../../shared/defguard-ui/types'; +import { ThemeSpacing } from '../../../../../shared/defguard-ui/types'; import { useAppForm } from '../../../../../shared/form'; import { formChangeLogic } from '../../../../../shared/formLogic'; import { joinCsv } from '../../../../../shared/utils/csv'; @@ -42,6 +37,7 @@ export const MicrosoftProviderForm = ({ onSubmit }: ProviderFormProps) => { directory_sync_target: providerState.directory_sync_target, directory_sync_user_behavior: providerState.directory_sync_user_behavior, prefetch_users: providerState.prefetch_users, + directory_sync_user_groups: joinCsv(providerState.directory_sync_user_groups), }), [providerState], ); @@ -57,6 +53,7 @@ export const MicrosoftProviderForm = ({ onSubmit }: ProviderFormProps) => { await onSubmit({ ...value, directory_sync_group_match: value.directory_sync_group_match ?? '', + directory_sync_user_groups: value.directory_sync_user_groups ?? '', }); }, }); @@ -119,14 +116,20 @@ export const MicrosoftProviderForm = ({ onSubmit }: ProviderFormProps) => { - - {m.settings_openid_provider_helper_microsoft_group_match()} - - {(field) => ( + )} + + + + {(field) => ( + )} @@ -146,6 +149,8 @@ export const MicrosoftProviderForm = ({ onSubmit }: ProviderFormProps) => { ...form.state.values, directory_sync_group_match: form.state.values.directory_sync_group_match ?? '', + directory_sync_user_groups: + form.state.values.directory_sync_user_groups ?? '', }); }} onNext={() => { @@ -153,6 +158,8 @@ export const MicrosoftProviderForm = ({ onSubmit }: ProviderFormProps) => { ...form.state.values, directory_sync_group_match: form.state.values.directory_sync_group_match ?? '', + directory_sync_user_groups: + form.state.values.directory_sync_user_groups ?? '', }); }} /> diff --git a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts index 6c07d9ee04..cce49cc242 100644 --- a/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts +++ b/web/src/pages/AddExternalOpenIdWizardPage/steps/AddExternalOpenIdDirectoryStep/forms/schemas.ts @@ -33,7 +33,8 @@ export const googleProviderSyncSchema = baseExternalProviderSyncSchema.extend({ export const microsoftProviderSyncSchema = baseExternalProviderSyncSchema.extend({ prefetch_users: z.boolean(), - directory_sync_group_match: z.string().trim(), + directory_sync_group_match: z.string().trim().nullable(), + directory_sync_user_groups: z.string().trim().nullable(), }); export const oktaProviderSyncSchema = baseExternalProviderSyncSchema.extend({ diff --git a/web/src/pages/AddExternalOpenIdWizardPage/useAddExternalOpenIdStore.tsx b/web/src/pages/AddExternalOpenIdWizardPage/useAddExternalOpenIdStore.tsx index 70db7fc483..92bf6c26ba 100644 --- a/web/src/pages/AddExternalOpenIdWizardPage/useAddExternalOpenIdStore.tsx +++ b/web/src/pages/AddExternalOpenIdWizardPage/useAddExternalOpenIdStore.tsx @@ -54,6 +54,7 @@ export const addExternalOpenIdStoreDefaults: StoreValues = { directory_sync_group_match: null, jumpcloud_api_key: null, prefetch_users: false, + directory_sync_user_groups: null, // Core settings create_account: false, diff --git a/web/src/pages/GatewaySetupPage/steps/SetupDeployGatewayStep.tsx b/web/src/pages/GatewaySetupPage/steps/SetupDeployGatewayStep.tsx index e996440c64..56b28ea134 100644 --- a/web/src/pages/GatewaySetupPage/steps/SetupDeployGatewayStep.tsx +++ b/web/src/pages/GatewaySetupPage/steps/SetupDeployGatewayStep.tsx @@ -215,7 +215,7 @@ const VirtualImageTab = () => { { const user = useAddUserModal((s) => s.user as User); const [selected, setSelected] = useState(new Set()); + const handleStepFinish = useCallback(() => { + if (enrollEnabled) { + useAddUserModal.setState({ + step: 'enrollment', + }); + } else { + useAddUserModal.setState({ + isOpen: false, + }); + } + }, [enrollEnabled]); + const { mutate, isPending } = useMutation({ mutationFn: api.group.addUsersToGroups, meta: { invalidate: [['group'], ['group-info'], ['user']], }, - onSuccess: () => { - if (enrollEnabled) { - useAddUserModal.setState({ - step: 'enrollment', - }); - } else { - useAddUserModal.setState({ - isOpen: false, - }); - } - }, + onSuccess: handleStepFinish, }); const options = useMemo( @@ -566,9 +568,7 @@ const AddUserGroupsSelectionStep = () => { groups: groups, }); } else { - useAddUserModal.setState({ - isOpen: false, - }); + handleStepFinish(); } }, }} diff --git a/web/src/pages/settings/SettingsEditOpenIdProviderPage/SettingsEditOpenIdProviderPage.tsx b/web/src/pages/settings/SettingsEditOpenIdProviderPage/SettingsEditOpenIdProviderPage.tsx index 6bd745724b..b88db3d7db 100644 --- a/web/src/pages/settings/SettingsEditOpenIdProviderPage/SettingsEditOpenIdProviderPage.tsx +++ b/web/src/pages/settings/SettingsEditOpenIdProviderPage/SettingsEditOpenIdProviderPage.tsx @@ -64,6 +64,7 @@ export const SettingsEditOpenIdProviderPage = () => { const normalizedFormData = { ...formData, directory_sync_group_match: joinCsv(formData.directory_sync_group_match), + directory_sync_user_groups: joinCsv(formData.directory_sync_user_groups), }; const submitValues = { ...normalizedFormData, ...values }; await mutateAsync(submitValues); diff --git a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx index 689f82a061..857ae0d417 100644 --- a/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx +++ b/web/src/pages/settings/SettingsEditOpenIdProviderPage/form/EditMicrosoftProviderForm.tsx @@ -29,7 +29,8 @@ const basicSchema = z .string(m.form_error_required()) .trim() .min(1, m.form_error_required()), - directory_sync_group_match: z.string().trim().optional(), + directory_sync_group_match: z.string().trim().nullable(), + directory_sync_user_groups: z.string().trim().nullable(), }) .extend(omit(baseExternalProviderConfigSchema.shape, ['base_url'])); @@ -86,6 +87,13 @@ export const EditMicrosoftProviderForm = ({ ? [provider.directory_sync_group_match] : null, ), + directory_sync_user_groups: joinCsv( + Array.isArray(provider.directory_sync_user_groups) + ? provider.directory_sync_user_groups + : provider.directory_sync_user_groups + ? [provider.directory_sync_user_groups] + : null, + ), microsoftTenantId: tenantId, }; }, [provider]); @@ -103,6 +111,7 @@ export const EditMicrosoftProviderForm = ({ ...value, base_url, directory_sync_group_match: value.directory_sync_group_match ?? '', + directory_sync_user_groups: value.directory_sync_user_groups ?? '', }); }, }); @@ -240,6 +249,15 @@ export const EditMicrosoftProviderForm = ({ )} + + {(field) => ( + + )} + + {(field) => ( { /> )} - ({ isDefault: s.isDefaultValue || s.isPristine, diff --git a/web/src/pages/settings/SettingsSmtpPage/SettingsSmtpPage.tsx b/web/src/pages/settings/SettingsSmtpPage/SettingsSmtpPage.tsx index 76201c1a5f..eeaff4abe0 100644 --- a/web/src/pages/settings/SettingsSmtpPage/SettingsSmtpPage.tsx +++ b/web/src/pages/settings/SettingsSmtpPage/SettingsSmtpPage.tsx @@ -1,11 +1,13 @@ +import './style.scss'; import { useMutation, useQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useMemo, useRef, useState } from 'react'; import z from 'zod'; import { m } from '../../../paraglide/messages'; import api from '../../../shared/api/api'; import { type Settings, + SmtpAuthentication, SmtpEncryption, type SmtpEncryptionValue, } from '../../../shared/api/types'; @@ -14,15 +16,9 @@ import { ContextualHelpKey, ContextualHelpSidebar, } from '../../../shared/components/ContextualHelp'; -import { Controls } from '../../../shared/components/Controls/Controls'; -import { DescriptionBlock } from '../../../shared/components/DescriptionBlock/DescriptionBlock'; import { Page } from '../../../shared/components/Page/Page'; -import { SettingsCard } from '../../../shared/components/SettingsCard/SettingsCard'; import { SettingsHeader } from '../../../shared/components/SettingsHeader/SettingsHeader'; import { SettingsLayout } from '../../../shared/components/SettingsLayout/SettingsLayout'; -import { Button } from '../../../shared/defguard-ui/components/Button/Button'; -import { EvenSplit } from '../../../shared/defguard-ui/components/EvenSplit/EvenSplit'; -import type { SelectOption } from '../../../shared/defguard-ui/components/Select/types'; import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { Snackbar } from '../../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../../shared/defguard-ui/types'; @@ -33,10 +29,24 @@ import { openModal } from '../../../shared/hooks/modalControls/modalsSubjects'; import { ModalName } from '../../../shared/hooks/modalControls/modalTypes'; import { useApp } from '../../../shared/hooks/useApp'; import { patternValidEmail } from '../../../shared/patterns'; -import { getSettingsQueryOptions } from '../../../shared/query'; +import { + getLicenseInfoQueryOptions, + getSettingsQueryOptions, +} from '../../../shared/query'; +import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; import { Validate } from '../../../shared/validate'; import { getConfiguredBadge, getNotConfiguredBadge } from '../SettingsIndexPage/types'; +import { + type SmtpAuthCardVariant, + SmtpAuthMethodCard, +} from './components/SmtpAuthMethodCard/SmtpAuthMethodCard'; import { SendTestEmailModal } from './SendTestEmailModal'; +import { + type SmtpAuthApplyResult, + SmtpAuthConfigModal, + type SmtpAuthModalValues, +} from './SmtpAuthConfigModal'; +import { isGoogleIssuerUrl, isMicrosoftIssuerUrl } from './smtpAuthUtils'; const breadcrumbsLinks = [ { icon="mail" badgeProps={smtp ? getConfiguredBadge() : getNotConfiguredBadge()} /> - {isPresent(settings) && ( - - -

{m.settings_smtp_section_server_description()}

-
- - -
- )} + {isPresent(settings) && } ); }; -const encryptionValueToLabel = (value: SmtpEncryptionValue): string => { - switch (value) { - case 'ImplicitTls': - return m.settings_smtp_encryption_implicit_tls(); - case 'StartTls': - return m.settings_smtp_encryption_start_tls(); - case 'None': - return m.settings_smtp_encryption_none(); +const detectActiveCard = ( + authentication: string, + issuerUrl: string | null, + smtpServer: string, +): SmtpAuthCardVariant | null => { + if (!smtpServer) return null; + if (authentication === SmtpAuthentication.None) return 'none'; + if (authentication === SmtpAuthentication.Login) return 'basic'; + if (authentication === SmtpAuthentication.XOAuth2) { + if (isGoogleIssuerUrl(issuerUrl)) return 'google'; + if (isMicrosoftIssuerUrl(issuerUrl)) return 'microsoft'; + return 'custom'; } + return null; }; -const encryptionSelectOptions: SelectOption[] = Object.values( - SmtpEncryption, -).map((e) => ({ - key: e, - label: encryptionValueToLabel(e), - value: e, -})); +const AUTH_CARDS: SmtpAuthCardVariant[] = ['none', 'basic', 'google', 'microsoft']; -const Content = ({ settings }: { settings: Settings }) => { - const smtpConfigured = useApp((s) => s.appInfo.smtp_enabled); - const formSchema = useMemo( - () => - z.object({ - smtp_server: z - .string() - .trim() - .min(1, m.form_error_required()) - .refine((val) => - !val - ? true - : Validate.any( - val, - [Validate.IPv4, Validate.IPv6, Validate.Domain, Validate.Hostname], - false, - ), +const formSchema = z.object({ + smtp_server: z + .string() + .trim() + .min(1, m.form_error_required()) + .refine((val) => + !val + ? true + : Validate.any( + val, + [Validate.IPv4, Validate.IPv6, Validate.Domain, Validate.Hostname], + false, ), - smtp_port: z.number(m.form_error_required()).max(65535, m.form_error_port_max()), - smtp_password: z.string().trim().nullable(), - smtp_user: z.string().trim().nullable(), - smtp_sender: z - .string() - .trim() - .min(1, m.form_error_required()) - .regex(patternValidEmail, m.form_error_email()), - smtp_encryption: z.enum(SmtpEncryption), - }), - [], - ); + ), + smtp_port: z.number(m.form_error_required()).max(65535, m.form_error_port_max()), + smtp_password: z.string().trim().nullable(), + smtp_user: z.string().trim().nullable(), + smtp_sender: z + .string() + .trim() + .min(1, m.form_error_required()) + .regex(patternValidEmail, m.form_error_email()), + smtp_encryption: z.enum(SmtpEncryption), + smtp_authentication: z.enum(SmtpAuthentication), + smtp_oauth_issuer_url: z.string().trim().nullable(), + smtp_oauth_client_id: z.string().trim().nullable(), + smtp_oauth_client_secret: z.string().trim().nullable(), + smtp_oauth_refresh_token: z.string().trim().nullable(), + smtp_oauth_tenant_id: z.string().trim().nullable(), +}); - type FormFields = z.infer; +type FormFields = z.infer; - const emptyValues = useMemo( - (): FormFields => ({ - smtp_encryption: SmtpEncryption.StartTls, - smtp_password: null, - smtp_port: 587, - smtp_sender: '', - smtp_server: '', - smtp_user: null, - }), - [], - ); +const emptyValues: FormFields = { + smtp_encryption: SmtpEncryption.StartTls, + smtp_password: null, + smtp_port: 587, + smtp_sender: '', + smtp_server: '', + smtp_user: null, + smtp_authentication: SmtpAuthentication.None, + smtp_oauth_issuer_url: null, + smtp_oauth_client_id: null, + smtp_oauth_client_secret: null, + smtp_oauth_refresh_token: null, + smtp_oauth_tenant_id: null, +}; + +const Content = ({ + settings, + smtpEnabled, +}: { + settings: Settings; + smtpEnabled: boolean; +}) => { + const [modalVariant, setModalVariant] = useState(null); + const modalInitialValuesRef = useRef({ + smtp_server: '', + smtp_port: 587, + smtp_sender: '', + smtp_encryption: SmtpEncryption.StartTls, + smtp_user: null, + smtp_password: null, + smtp_oauth_issuer_url: null, + smtp_oauth_client_id: null, + smtp_oauth_client_secret: null, + smtp_oauth_refresh_token: null, + smtp_oauth_tenant_id: null, + }); const defaultValues = useMemo( (): FormFields => ({ @@ -156,10 +182,20 @@ const Content = ({ settings }: { settings: Settings }) => { smtp_sender: settings.smtp_sender ?? '', smtp_server: settings.smtp_server ?? '', smtp_user: settings.smtp_user ?? null, + smtp_authentication: settings.smtp_authentication, + smtp_oauth_issuer_url: settings.smtp_oauth_issuer_url ?? null, + smtp_oauth_client_id: settings.smtp_oauth_client_id ?? null, + smtp_oauth_client_secret: settings.smtp_oauth_client_secret ?? null, + smtp_oauth_refresh_token: settings.smtp_oauth_refresh_token ?? null, + smtp_oauth_tenant_id: settings.smtp_oauth_tenant_id ?? null, }), [settings], ); + const { data: licenseInfo } = useQuery(getLicenseInfoQueryOptions); + const oauthLocked = + licenseInfo !== undefined && !canUseBusinessFeature(licenseInfo).result; + const { mutateAsync: editSettings } = useMutation({ mutationFn: api.settings.patchSettings, meta: { @@ -177,140 +213,175 @@ const Content = ({ settings }: { settings: Settings }) => { defaultValues, validationLogic: formChangeLogic, validators: { - onSubmit: formSchema, onChange: formSchema, }, - onSubmit: async ({ value }) => { - await editSettings(value); - form.reset(value); - }, + onSubmit: async () => {}, }); + const openConfigModal = (variant: SmtpAuthCardVariant) => { + modalInitialValuesRef.current = { + smtp_server: form.state.values.smtp_server, + smtp_port: form.state.values.smtp_port, + smtp_sender: form.state.values.smtp_sender, + smtp_encryption: form.state.values.smtp_encryption as SmtpEncryptionValue, + smtp_user: form.state.values.smtp_user, + smtp_password: form.state.values.smtp_password, + smtp_oauth_issuer_url: form.state.values.smtp_oauth_issuer_url, + smtp_oauth_client_id: form.state.values.smtp_oauth_client_id, + smtp_oauth_client_secret: form.state.values.smtp_oauth_client_secret, + smtp_oauth_refresh_token: form.state.values.smtp_oauth_refresh_token, + smtp_oauth_tenant_id: form.state.values.smtp_oauth_tenant_id, + }; + setModalVariant(variant); + }; + + const openConfigModalGated = (variant: SmtpAuthCardVariant) => { + if (variant === 'google' || variant === 'microsoft') { + if (licenseInfo === undefined) return; + licenseActionCheck(canUseBusinessFeature(licenseInfo), () => + openConfigModal(variant), + ); + } else { + openConfigModal(variant); + } + }; + + const handleDelete = () => { + openModal(ModalName.ConfirmAction, { + title: m.settings_smtp_reset_confirm_title(), + contentMd: m.settings_smtp_reset_confirm_body(), + actionPromise: () => { + return api.settings.patchSettings(emptyValues); + }, + invalidateKeys: [['settings'], ['info']], + submitProps: { text: m.controls_delete(), variant: 'critical' }, + onSuccess: () => { + form.reset(emptyValues); + Snackbar.default(m.settings_smtp_reset_success()); + }, + onError: () => Snackbar.error(m.settings_smtp_reset_failed()), + }); + }; + return ( -
{ - e.stopPropagation(); - e.preventDefault(); - form.handleSubmit(); - }} - > - - - - {(field) => ( - - )} - - - {(field) => ( - - )} - - - - - - {(field) => ( - - )} - - - {(field) => ( - - )} - - - - - - {(field) => ( - - )} - - - {(field) => ( - - )} - - - ({ - isDefaultValue: s.isDefaultValue || s.isPristine, - isSubmitting: s.isSubmitting, - })} - > - {({ isDefaultValue, isSubmitting }) => ( - - {smtpConfigured && ( -