From 2550885c794c7ce71bf815d8382f433cc0dab37f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Fri, 21 Oct 2022 09:40:10 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 1 + .env | 14 + .github/workflows/ci.yml | 62 + .github/workflows/release.yml | 51 + .gitignore | 3 + .gitmodules | 3 + CONTRIBUTING.md | 53 + Cargo.lock | 3973 +++++++++++++++++ Cargo.toml | 84 + Dockerfile | 40 + Dockerfile.ci | 7 + Dockerfile.device | 12 + Dockerfile.ldap | 3 + Openid.md | 84 + README.md | 100 + build.rs | 14 + docker-compose.ldap.yaml | 65 + docker-compose.yaml | 56 + docs/header.png | Bin 0 -> 4445 bytes docs/network-overview.png | Bin 0 -> 156060 bytes k8s/base/config.env | 5 + k8s/base/core-deployment.yaml | 41 + k8s/base/core-ingress.yaml | 18 + k8s/base/core-service-grpc.yaml | 18 + k8s/base/core-service.yaml | 16 + k8s/base/db-deployment.yaml | 51 + k8s/base/db-persistentvolumeclaim.yaml | 10 + k8s/base/db-service.yaml | 15 + k8s/base/kustomization.yaml | 11 + k8s/overlays/dev/kustomization.yaml | 5 + k8s/overlays/dev/ldap-deployment.yaml | 42 + k8s/overlays/dev/ldap-service.yaml | 16 + k8s/overlays/dev/ldap-storage.yaml | 10 + k8s/overlays/prod/config.env | 4 + k8s/overlays/prod/ingress-patch.json | 7 + k8s/overlays/prod/kustomization.yaml | 12 + ldap-initdb.d/set_access.sh | 18 + ldif/custom.ldif | 14 + ldif/gnupg-ldap-schema.ldif | 209 + ldif/init.ldif | 39 + ldif/openssh-lpk_openldap.ldif | 9 + ldif/orion.ldif | 13 + ldif/samba.ldif | 224 + migrations/20220922092725_initial.down.sql | 19 + migrations/20220922092725_initial.up.sql | 196 + migrations/20221006125653_web3_mfa.down.sql | 1 + migrations/20221006125653_web3_mfa.up.sql | 1 + migrations/20221010085106_extend_mfa.down.sql | 5 + migrations/20221010085106_extend_mfa.up.sql | 10 + migrations/20221012084225_inet.down.sql | 6 + migrations/20221012084225_inet.up.sql | 6 + migrations/20221017101958_openid.down.sql | 5 + migrations/20221017101958_openid.up.sql | 5 + model-derive/Cargo.toml | 11 + model-derive/src/lib.rs | 186 + proto/build.sh | 40 + proto/client/local_state.proto | 12 + proto/core/auth.proto | 16 + proto/core/vpn.proto | 88 + proto/wireguard/gateway.proto | 48 + proto/worker/worker.proto | 30 + sqlx-data.json | 3912 ++++++++++++++++ src/appstate.rs | 100 + src/auth/mod.rs | 234 + src/bin/defguard.rs | 83 + src/config.rs | 139 + src/db/mod.rs | 36 + src/db/models/device.rs | 190 + src/db/models/error.rs | 38 + src/db/models/group.rs | 93 + src/db/models/mod.rs | 93 + src/db/models/session.rs | 164 + src/db/models/settings.rs | 16 + src/db/models/user.rs | 411 ++ src/db/models/wallet.rs | 181 + src/db/models/webauthn.rs | 53 + src/db/models/webhook.rs | 82 + src/db/models/wireguard.rs | 634 +++ src/enterprise/db/mod.rs | 1 + src/enterprise/db/openid.rs | 176 + src/enterprise/grpc/mod.rs | 43 + src/enterprise/grpc/worker.rs | 282 ++ src/enterprise/handlers/mod.rs | 8 + src/enterprise/handlers/oauth.rs | 64 + src/enterprise/handlers/openid_clients.rs | 178 + src/enterprise/handlers/openid_flow.rs | 173 + src/enterprise/handlers/worker.rs | 124 + src/enterprise/ldap/error.rs | 25 + src/enterprise/ldap/hash.rs | 46 + src/enterprise/ldap/mod.rs | 280 ++ src/enterprise/ldap/model.rs | 126 + src/enterprise/ldap/utils.rs | 73 + src/enterprise/mod.rs | 12 + src/enterprise/oauth_db.rs | 306 ++ src/enterprise/oauth_state.rs | 332 ++ src/enterprise/openid_idtoken.rs | 108 + src/enterprise/openid_state.rs | 145 + src/error.rs | 71 + src/grpc/auth.rs | 50 + src/grpc/gateway.rs | 278 ++ src/grpc/interceptor.rs | 27 + src/grpc/mod.rs | 61 + src/handlers/auth.rs | 300 ++ src/handlers/group.rs | 153 + src/handlers/license.rs | 16 + src/handlers/mod.rs | 208 + src/handlers/settings.rs | 31 + src/handlers/user.rs | 321 ++ src/handlers/webhooks.rs | 134 + src/handlers/wireguard.rs | 360 ++ src/hex.rs | 73 + src/lib.rs | 281 ++ src/license.rs | 131 + src/oxide_auth_rocket/failure.rs | 67 + src/oxide_auth_rocket/mod.rs | 227 + tests/auth.rs | 229 + tests/common/mod.rs | 42 + tests/license.rs | 127 + tests/oauth.rs | 102 + tests/openid.rs | 396 ++ tests/settings.rs | 66 + tests/user.rs | 304 ++ tests/webhook.rs | 79 + tests/wireguard.rs | 571 +++ 124 files changed, 19783 insertions(+) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 Dockerfile.ci create mode 100644 Dockerfile.device create mode 100644 Dockerfile.ldap create mode 100644 Openid.md create mode 100644 README.md create mode 100644 build.rs create mode 100644 docker-compose.ldap.yaml create mode 100644 docker-compose.yaml create mode 100644 docs/header.png create mode 100644 docs/network-overview.png create mode 100644 k8s/base/config.env create mode 100644 k8s/base/core-deployment.yaml create mode 100644 k8s/base/core-ingress.yaml create mode 100644 k8s/base/core-service-grpc.yaml create mode 100644 k8s/base/core-service.yaml create mode 100644 k8s/base/db-deployment.yaml create mode 100644 k8s/base/db-persistentvolumeclaim.yaml create mode 100644 k8s/base/db-service.yaml create mode 100644 k8s/base/kustomization.yaml create mode 100644 k8s/overlays/dev/kustomization.yaml create mode 100644 k8s/overlays/dev/ldap-deployment.yaml create mode 100644 k8s/overlays/dev/ldap-service.yaml create mode 100644 k8s/overlays/dev/ldap-storage.yaml create mode 100644 k8s/overlays/prod/config.env create mode 100644 k8s/overlays/prod/ingress-patch.json create mode 100644 k8s/overlays/prod/kustomization.yaml create mode 100755 ldap-initdb.d/set_access.sh create mode 100644 ldif/custom.ldif create mode 100644 ldif/gnupg-ldap-schema.ldif create mode 100644 ldif/init.ldif create mode 100644 ldif/openssh-lpk_openldap.ldif create mode 100644 ldif/orion.ldif create mode 100644 ldif/samba.ldif create mode 100644 migrations/20220922092725_initial.down.sql create mode 100644 migrations/20220922092725_initial.up.sql create mode 100644 migrations/20221006125653_web3_mfa.down.sql create mode 100644 migrations/20221006125653_web3_mfa.up.sql create mode 100644 migrations/20221010085106_extend_mfa.down.sql create mode 100644 migrations/20221010085106_extend_mfa.up.sql create mode 100644 migrations/20221012084225_inet.down.sql create mode 100644 migrations/20221012084225_inet.up.sql create mode 100644 migrations/20221017101958_openid.down.sql create mode 100644 migrations/20221017101958_openid.up.sql create mode 100644 model-derive/Cargo.toml create mode 100644 model-derive/src/lib.rs create mode 100755 proto/build.sh create mode 100644 proto/client/local_state.proto create mode 100644 proto/core/auth.proto create mode 100644 proto/core/vpn.proto create mode 100644 proto/wireguard/gateway.proto create mode 100644 proto/worker/worker.proto create mode 100644 sqlx-data.json create mode 100644 src/appstate.rs create mode 100644 src/auth/mod.rs create mode 100644 src/bin/defguard.rs create mode 100644 src/config.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/models/device.rs create mode 100644 src/db/models/error.rs create mode 100644 src/db/models/group.rs create mode 100644 src/db/models/mod.rs create mode 100644 src/db/models/session.rs create mode 100644 src/db/models/settings.rs create mode 100644 src/db/models/user.rs create mode 100644 src/db/models/wallet.rs create mode 100644 src/db/models/webauthn.rs create mode 100644 src/db/models/webhook.rs create mode 100644 src/db/models/wireguard.rs create mode 100644 src/enterprise/db/mod.rs create mode 100644 src/enterprise/db/openid.rs create mode 100644 src/enterprise/grpc/mod.rs create mode 100644 src/enterprise/grpc/worker.rs create mode 100644 src/enterprise/handlers/mod.rs create mode 100644 src/enterprise/handlers/oauth.rs create mode 100644 src/enterprise/handlers/openid_clients.rs create mode 100644 src/enterprise/handlers/openid_flow.rs create mode 100644 src/enterprise/handlers/worker.rs create mode 100644 src/enterprise/ldap/error.rs create mode 100644 src/enterprise/ldap/hash.rs create mode 100644 src/enterprise/ldap/mod.rs create mode 100644 src/enterprise/ldap/model.rs create mode 100644 src/enterprise/ldap/utils.rs create mode 100644 src/enterprise/mod.rs create mode 100644 src/enterprise/oauth_db.rs create mode 100644 src/enterprise/oauth_state.rs create mode 100644 src/enterprise/openid_idtoken.rs create mode 100644 src/enterprise/openid_state.rs create mode 100644 src/error.rs create mode 100644 src/grpc/auth.rs create mode 100644 src/grpc/gateway.rs create mode 100644 src/grpc/interceptor.rs create mode 100644 src/grpc/mod.rs create mode 100644 src/handlers/auth.rs create mode 100644 src/handlers/group.rs create mode 100644 src/handlers/license.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/handlers/settings.rs create mode 100644 src/handlers/user.rs create mode 100644 src/handlers/webhooks.rs create mode 100644 src/handlers/wireguard.rs create mode 100644 src/hex.rs create mode 100644 src/lib.rs create mode 100644 src/license.rs create mode 100644 src/oxide_auth_rocket/failure.rs create mode 100644 src/oxide_auth_rocket/mod.rs create mode 100644 tests/auth.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/license.rs create mode 100644 tests/oauth.rs create mode 100644 tests/openid.rs create mode 100644 tests/settings.rs create mode 100644 tests/user.rs create mode 100644 tests/webhook.rs create mode 100644 tests/wireguard.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/.env b/.env new file mode 100644 index 000000000..abc338bea --- /dev/null +++ b/.env @@ -0,0 +1,14 @@ +DATABASE_URL="postgresql://defguard:defguard@localhost/defguard" +DEFGUARD_JWT_SECRET=orion-secret +DEFGUARD_LDAP_URL=ldap://localhost:389 +DEFGUARD_LDAP_SERVICE_PASSWORD=adminpassword +DEFGUARD_LDAP_USER_SEARCH_BASE="ou=users,dc=example,dc=org" +DEFGUARD_LDAP_GROUP_SEARCH_BASE="ou=groups,dc=example,dc=org" +DEFGUARD_LDAP_DEVICE_SEARCH_BASE="ou=devices,dc=example,dc=org" +DEFGUARD_OAUTH_ENABLED=true +DEFGUARD_DB_HOST="localhost" +DEFGUARD_DB_PORT=5432 +DEFGUARD_DB_NAME="defguard" +DEFGUARD_DB_USER="defguard" +DEFGUARD_DB_PASSWORD="defguard" +DEFGUARD_DATABASE_URL="postgresql://defguard:defguard@localhost/defguard" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..4b144616e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: Continuous integration + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + container: rust:latest + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_DB: defguard + POSTGRES_USER: defguard + POSTGRES_PASSWORD: defguard + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Debug + run: echo ${{ github.ref_name }} + - name: Checkout + uses: actions/checkout@v3 + with: + submodules: recursive + - name: Cache + uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Set database URL + run: sed -i -e 's,localhost,postgres,' .env + - name: Install protoc + run: apt-get update && apt-get -y install protobuf-compiler + - name: Run tests + env: + DEFGUARD_DB_HOST: postgres + DEFGUARD_DB_PORT: 5432 + DEFGUARD_DB_NAME: defguard + DEFGUARD_DB_USER: defguard + DEFGUARD_DB_PASSWORD: defguard + DEFGUARD_LICENSE: BwAAAAAAAAB0ZW9uaXRlCgAAAAAAAAAyMDUwLTEwLTEwAAAAAAFiayfBptq8pZXjPo4FV3VnmmwR/ipZHLriVPTW3AFyRq4c2wR+DzWC4BUACu3YMS27kX116JVKWB3/edYKNELFSiqYc6vsfoOrXnnQQJDI8RoyAQB6MpLv/EcgRZh47iI4L+tp44jKFQZ+EqqvMNt3G41u13P72HdkUv8yzQ7dmm3BrYQGJSCh/xiLna+mtQ9IQdqXOmYVInPXiWtIvi157Utfnow3gS0Ak45jci0DhtH+RWmFfiMOQCc4Qx0kEF9PsHl6Hn9Ay4oRTAnSYEPdWfQlVh5Rp276bLqnHDdyJ3/o2RSNK+QUXR7V2iuN1M3sWyW1rCGXtV5miHGI97CS + SQLX_OFFLINE: true + run: cargo test --locked --no-fail-fast --features mock-license-key diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..f1e8d6251 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Publish to Docker Registry +on: + push: + tags: + - v*.*.* + pull_request: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Cache + uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: | + DefGuard/core + ghcr.io/DefGuard/core + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + - name: Login to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build container + uses: docker/build-push-action@v3 + with: + # platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..85f307e89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +defguard.db* +.volumes diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..bf824a5a4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "proto"] + path = proto + url = ../proto.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..26d187cfc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing + +1. Sqlx offline build + +Requires `sqlx-data.json` to be present in the root directory of the project. Create the file using: + +``` +cargo sqlx prepare -- --lib +``` + +2. Build docker image + +``` +docker-compose build +``` + +3. Run + +``` +docker-compose up +``` + +## Configuration + +Following environment variables can be set to configure orion core service: + +* **DEFGUARD_ADMIN_GROUPNAME**: groupname that give a user privileged access + +### Authorization + +* **DEFGUARD_JWT_SECRET**: Json Web Token secret, used to encode/decode JWT tokens + +### LDAP + +* **DEFGUARD_LDAP_URL**: URL to read users and devices data (e.g. `http://localhost:389`) +* **DEFGUARD_LDAP_GROUP_SEARCH_BASE**: group search base, default: `ou=groups,dc=example,dc=org` +* **DEFGUARD_LDAP_USER_SEARCH_BASE**: user search base, default: `dc=example,dc=org` +* **DEFGUARD_LDAP_USER_OBJ_CLASS**: user object class, default: `inetOrgPerson` +* **DEFGUARD_LDAP_GROUP_OBJ_CLASS**: group object class, default: `groupOfUniqueNames` +* **DEFGUARD_LDAP_USERNAME_ATTR**: naming attribute for users, should be `cn` or `uid`, default: `cn` +* **DEFGUARD_LDAP_GROUPNAME_ATTR**: naming attribute for groups, default: `cn` +* **DEFGUARD_LDAP_MEMBER_ATTR**: user attribute for group membership +* **DEFGUARD_LDAP_GROUP_MEMBER_ATTR**: group attibute for memebers + +### gRPC + +* **DEFGUARD_GRPC_PORT**: gRPC services bind port, default = `50055` + +### HTTP server + +* **DEFGUARD_WEB_PORT**: web services bind port, default = `8000` +* **DEFGUARD_OAUTH_ENABLED**: enable OAuth 2.0 support +* **DEFGUARD_WG_SERVICE_URL**: WireGuard service instance to connect to diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..7742a8eec --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3973 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aead" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c192eb8f11fc081b0fe4259ba5af04217d4e0faddd02417310a927911abd7c8" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe0133578c0986e1fe3dfcd4af1cc5b2dd6c3dbf534d69916ce16a2701d40ba" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e1366e0c69c9f927b1fa5ce2c7bf9eafc8f9268c0b9800729e8b267612447c" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom 0.2.8", + "once_cell", + "version_check", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" + +[[package]] +name = "argon2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db4ce4441f99dbd377ca8a8f57b698c44d0d6e712d8329b5040da5a64aa1ce73" +dependencies = [ + "base64ct", + "blake2", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" + +[[package]] +name = "arrayvec" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" + +[[package]] +name = "asn1-rs" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ff05a702273012438132f449575dbc804e27b2f3cbe3069aa237d26c98fa33" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.1", + "num-traits", + "rusticata-macros", + "thiserror", + "time 0.3.15", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8b7511298d5b7784b40b092d9e9dcd3a627a5707e4b5e507931ab0d44eeebf" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e" +dependencies = [ + "async-stream-impl", + "futures-core", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e805d94e6b5001b651426cf4cd446b1ab5f319d27bab5c644f61de0a804360c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c57d12312ff59c811c0643f4d80830505833c9ffaebd193d819392b265be8e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b88d82667eca772c4aa12f0f1348b3ae643424c8876448f3f7bd5787032e234c" +dependencies = [ + "autocfg 1.1.0", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dde43e75fd43e8a1bf86103336bc699aa8d17ad1be60c76c0bdfd4828e19b78" +dependencies = [ + "autocfg 1.1.0", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "axum" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acee9fd5073ab6b045a275b3e709c163dd36c90685219cb21804a147b58dba43" +dependencies = [ + "async-trait", + "axum-core", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e5939e02c56fecd5c017c37df4238c0a839fa76b7f97acdd7efb804fd181cc" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + +[[package]] +name = "base64" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "base64ct" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf" + +[[package]] +name = "base64urlsafedata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d02340c3f25c8422ba85d481123406dd7506505485bac1c694b26eb538da8daf" +dependencies = [ + "base64 0.13.0", + "serde", + "serde_json", +] + +[[package]] +name = "binascii" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "blake2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cf849ee05b2ee5fba5e36f97ff8ec2533916700fc0758d40d92136a42f3388" +dependencies = [ + "digest 0.10.5", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72936ee4afc7f8f736d1c38383b56480b5497b4617b4a77bdbf1d2ababc76127" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db" + +[[package]] +name = "cc" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-integer", + "num-traits", + "serde", + "time 0.1.44", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "cipher" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1873270f8f7942c191139cb8a40fd228da6c3fd2fc376d7e92d47aa14aeb59e" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "3.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86447ad904c7fb335a790c9d7fe3d0d971dc523b8ccd1561a520de9a85302750" +dependencies = [ + "atty", + "bitflags", + "clap_derive", + "clap_lex", + "indexmap", + "once_cell", + "strsim", + "termcolor", + "textwrap", +] + +[[package]] +name = "clap_derive" +version = "3.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "colored" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ffc801dacf156c5854b9df4f425a626539c3a6ef7893cc0c5084a23f0b6c59" +dependencies = [ + "atty", + "lazy_static", + "winapi", +] + +[[package]] +name = "compact_jwt" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5656b98b1584764a52906e67caec20dfb9b0179ac2052d0d5937b083bc39a120" +dependencies = [ + "base64 0.13.0", + "base64urlsafedata", + "hex", + "openssl", + "serde", + "serde_json", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "const-oid" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6f2aa4d0537bcc1c74df8755072bd31c1ef1a3a1b85a68e8404a8c353b7b8b" + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "cookie" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "344adc371239ef32293cb1c4fe519592fcf21206c79c02854320afcdf3ab4917" +dependencies = [ + "aes-gcm", + "base64 0.13.0", + "hkdf", + "hmac", + "percent-encoding", + "rand", + "sha2", + "subtle", + "time 0.3.15", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + +[[package]] +name = "cpufeatures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53757d12b596c16c78b83458d732a5d1a17ab3f53f2f7412f6fb57cc8a140ab3" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d0165d2900ae6778e36e80bbc4da3b5eefccee9ba939761f9c2882a5d9af3ff" + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd42583b04998a5363558e5f9291ee5a5ff6b49944332103f251e7479a82aa7" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edbafec5fa1f196ca66527c1b12c2ec4745ca14b50f1ad8f9f6f720b55d11fac" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + +[[package]] +name = "crypto-bigint" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83bd3bb4314701c568e340cd8cf78c975aa0ca79e03d3f6d1677d5b0c9c0c03" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f9d052967f590a76e62eb387bd0bbb1b000182c3cefe5364db6b7211651bc0" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + +[[package]] +name = "cxx" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f83d0ebf42c6eafb8d7c52f7e5f2d3003b89c7aa4fd2b79229209459a849af8" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d050484b55975889284352b0ffc2ecbda25c0c55978017c132b29ba0818a86" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d2199b00553eda8012dfec8d3b1c75fce747cf27c169a270b3b99e3448ab78" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb67a6de1f602736dd7eaead0080cf3435df806c61b24b13328db128c58868f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee2393c4a91429dffb4bedf19f4d6abf27d8a732c8ce4980305d782e5426d57" + +[[package]] +name = "defguard" +version = "0.3.0" +dependencies = [ + "argon2", + "base64 0.13.0", + "bincode", + "chrono", + "clap", + "fern", + "ipnetwork", + "jsonwebtoken", + "ldap3", + "log", + "matches", + "md4", + "model_derive", + "openidconnect", + "otpauth", + "oxide-auth", + "oxide-auth-async", + "prost", + "rand", + "rand_core 0.5.1", + "reqwest", + "rocket", + "rsa", + "secp256k1", + "serde", + "serde_cbor_2", + "serde_urlencoded", + "sha-1", + "sqlx", + "tiny-keccak", + "tokio", + "tokio-stream", + "tonic", + "tonic-build", + "uuid", + "webauthn-authenticator-rs", + "webauthn-rs", + "x25519-dalek", +] + +[[package]] +name = "der" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4" +dependencies = [ + "const-oid", + "crypto-bigint", +] + +[[package]] +name = "der-parser" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe398ac75057914d7d07307bf67dc7f3f574a26783b4fc7805a20ffa9f506e82" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.1", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "devise" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c7580b072f1c8476148f16e0a0d5dedddab787da98d86c5082c5e9ed8ab595" +dependencies = [ + "devise_codegen", + "devise_core", +] + +[[package]] +name = "devise_codegen" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "123c73e7a6e51b05c75fe1a1b2f4e241399ea5740ed810b0e3e6cacd9db5e7b2" +dependencies = [ + "devise_core", + "quote", +] + +[[package]] +name = "devise_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841ef46f4787d9097405cac4e70fb8644fc037b526e8c14054247c0263c400d0" +dependencies = [ + "bitflags", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adfbc57365a37acbd2ebf2b64d7e69bb766e2fea813521ed536f5d0520dcf86c" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf95dc3f046b9da4f2d51833c0d3547d8564ef6910f5c1ed130306a75b92886" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d8c417d7a8cb362e0c37e5d815f5eb7c37f79ff93707329d5a194e42e54ca0" + +[[package]] +name = "either" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fastrand" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" +dependencies = [ + "instant", +] + +[[package]] +name = "fern" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bdd7b0849075e79ee9a1836df22c717d1eba30451796fdc631b04565dd11e2a" +dependencies = [ + "colored", + "log", +] + +[[package]] +name = "figment" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e56602b469b2201400dec66a66aec5a9b8761ee97cd1b8c96ab2483fcc16cc9" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ba265a92256105f45b719605a571ffe2d1f0fea3807304b522c1d778f79eed" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac" + +[[package]] +name = "futures-executor" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62007592ac46aa7c2b6416f7deb9a8a8f63a01e0f1d6e1787d5630170db2b63e" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot 0.11.2", +] + +[[package]] +name = "futures-io" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb" + +[[package]] +name = "futures-macro" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdfb8ce053d86b91919aad980c220b1fb8401a9394410e1c289ed7e66b61835d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c15cf1a4aa79df40f1bb462fb39676d0ad9e366c2a33b590d7c66f4f81fcf9" + +[[package]] +name = "futures-task" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea" + +[[package]] +name = "futures-util" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generator" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc184cace1cea8335047a471cc1da80f18acf8a76f3bab2028d499e328948ec7" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "windows", +] + +[[package]] +name = "generic-array" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" + +[[package]] +name = "h2" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca32592cf21ac7ccab1825cd87f6c9b3d9022c44d086172ed0966bec8af30be" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fe1fcf8b4278d860ad0548329f892a3631fb63f82574df68275f34cdbe0ffa" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.5", +] + +[[package]] +name = "http" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "hyper" +version = "0.14.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02c929dc5c39e335a03c405292728118860721b10190d98c2a0f0efd5baafbac" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a6ef98976b22b3b7f2f3a806f858cb862044cfa66805aa3ad84cb3d3b785ed" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" +dependencies = [ + "autocfg 1.1.0", + "hashbrown", + "serde", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879d54834c8c76457ef4293a689b2a8c59b076067ad77b15efafbb05f92a592b" + +[[package]] +name = "ipnetwork" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f84f1612606f3753f205a4e9a2efd6fe5b4c573a6269b2cc6c3003d44a0d127" +dependencies = [ + "serde", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc" + +[[package]] +name = "js-sys" +version = "0.3.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49409df3e3bf0856b916e2ceaca09ee28e6871cf7d9ce97a692cacfdb2a25a47" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "8.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa4b4af834c6cfd35d8763d359661b90f2e45d8f750a0849156c7f4671af09c" +dependencies = [ + "base64 0.13.0", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "lber" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a99b520993b21a6faab32643cf4726573dc18ca4cf2d48cbeb24d248c86c930" +dependencies = [ + "byteorder", + "bytes", + "nom 2.2.1", +] + +[[package]] +name = "ldap3" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef35dc747152dd47bdc6aaeb35a232f84cbc8d84ae4cb9673aea810a6570ab8f" +dependencies = [ + "async-trait", + "bytes", + "futures", + "futures-util", + "lazy_static", + "lber", + "log", + "native-tls", + "nom 2.2.1", + "percent-encoding", + "thiserror", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "libc" +version = "0.2.135" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68783febc7782c6c5cb401fbda4de5a9898be1762314da0bb2c10ced61f18b0c" + +[[package]] +name = "libm" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "292a948cd991e376cf75541fe5b97a1081d713c618b4f1b9500f8844e49eb565" + +[[package]] +name = "link-cplusplus" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9272ab7b96c9046fbc5bc56c06c117cb639fe2d509df0c421cad82d2915cf369" +dependencies = [ + "cc", +] + +[[package]] +name = "lock_api" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" +dependencies = [ + "autocfg 1.1.0", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "matchit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cbba799671b762df5a175adf59ce145165747bb891505c43d09aefbbf38beb" + +[[package]] +name = "md-5" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6365506850d44bff6e2fbcb5176cf63650e48bd45ef2fe2665ae1570e0f4b9ca" +dependencies = [ + "digest 0.10.5", +] + +[[package]] +name = "md4" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda" +dependencies = [ + "digest 0.10.5", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96590ba8f175222643a85693f33d26e9c8a015f599c216509b1a6894af675d34" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.36.1", +] + +[[package]] +name = "model_derive" +version = "0.1.0" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "multer" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed4198ce7a4cbd2a57af78d28c6fbb57d81ac5f1d6ad79ac6c5587419cbdf22" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "log", + "memchr", + "mime", + "spin 0.9.4", + "tokio", + "tokio-util", + "version_check", +] + +[[package]] +name = "multimap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" + +[[package]] +name = "native-tls" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd7e2f3618557f980e0b17e8856252eee3c97fa12c54dff0ca290fb6266ca4a9" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf51a729ecf40266a2368ad335a5fdde43471f545a967109cd62146ecf8b66ff" + +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +dependencies = [ + "autocfg 1.1.0", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4547ee5541c18742396ae2c895d0717d0f886d8823b8399cdaf7b07d63ad0480" +dependencies = [ + "autocfg 0.1.8", + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg 1.1.0", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg 1.1.0", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg 1.1.0", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + +[[package]] +name = "oauth2" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d62c436394991641b970a92e23e8eeb4eb9bca74af4f5badc53bcd568daadbd" +dependencies = [ + "base64 0.13.0", + "chrono", + "getrandom 0.2.8", + "http", + "rand", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url", +] + +[[package]] +name = "oid-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e20717fa0541f39bd146692035c37bedfa532b3e5071b35761082407546b2a" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "openidconnect" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e26afc60b2bf11b9a039db1f3a3c0d5fe201eebdbe646a8ecb8342c8240e3271" +dependencies = [ + "base64 0.13.0", + "chrono", + "http", + "itertools", + "log", + "num-bigint", + "oauth2", + "rand", + "ring", + "serde", + "serde-value", + "serde_derive", + "serde_json", + "serde_path_to_error", + "thiserror", + "url", +] + +[[package]] +name = "openssl" +version = "0.10.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12fc0523e3bd51a692c8850d075d74dc062ccf251c0110668cbd921917118a13" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5230151e44c0f05157effb743e8d517472843121cf9243e8b81393edb5acd9ce" +dependencies = [ + "autocfg 1.1.0", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7940cf2ca942593318d07fcf2596cdca60a85c9e7fab408a5e21a4f9dcd40d87" +dependencies = [ + "num-traits", +] + +[[package]] +name = "os_str_bytes" +version = "6.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff" + +[[package]] +name = "otpauth" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca79a3dc8a04388203b57a706221f2d98b5be8ad85db486e6f995777c35ae25" +dependencies = [ + "base32", + "byteorder", + "ring", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "oxide-auth" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e580c9b15905cc585d44bc6010113a3225c4b664d40d0a175d5a0a4022235d89" +dependencies = [ + "base64 0.13.0", + "chrono", + "hmac", + "once_cell", + "rand", + "rmp-serde", + "rust-argon2", + "serde", + "serde_derive", + "serde_json", + "sha2", + "subtle", + "url", +] + +[[package]] +name = "oxide-auth-async" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2ae7df8070b0a02bf81d67355af4ab4ae7ad5efc376d322f0239b75701ed4" +dependencies = [ + "async-trait", + "base64 0.12.3", + "chrono", + "oxide-auth", + "url", +] + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.5", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.4", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc9e0dc2adc1c69d09143aff38d3d30c5c3f0df0dad82e6d25547af174ebec0" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys 0.42.0", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1de2e551fb905ac83f73f7aedf2f0cb4a0da7e35efa24a202a936269f1f18e1" + +[[package]] +name = "pear" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e44241c5e4c868e3eaa78b7c1848cadd6344ed4f54d029832d32b415a58702" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a5ca643c2303ecb740d506539deba189e16f2754040a42901cd8105d0282d0" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "pem" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c64931a1a212348ec4f3b4362585eca7159d0d09cbdf4a7f74f02173596fd4" +dependencies = [ + "base64 0.13.0", +] + +[[package]] +name = "pem-rfc7468" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f22eb0e3c593294a99e9ff4b24cf6b752d43f193aa4415fe5077c159996d497" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" + +[[package]] +name = "petgraph" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5014253a1331579ce62aa67443b4a658c5e7dd03d4bc6d302b94474888143" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "116bee8279d783c0cf370efa1a94632f2108e5ef0bb32df31f051647810a4e2c" +dependencies = [ + "der", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "pkcs8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3ef9b64d26bad0536099c816c6734379e45bbd5f14798def6809e5cc350447" +dependencies = [ + "der", + "pem-rfc7468", + "pkcs1", + "spki", + "zeroize", +] + +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "polyval" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef234e08c11dfcb2e56f79fd70f6f2eb7f025c0ce2333e82f4f0518ecad30c6" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "prettyplease" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c142c0e46b57171fe0c528bee8c5b7569e80f0c17e377cd0e30ea57dbc11bb51" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf29726d67464d49fa6224a1d07936a8c08bb3fba727c7493f6cf1616fdaada" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "prost" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "399c3c31cdec40583bb68f0b18403400d01ec4289c383aa047560439952c4dd7" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f835c582e6bd972ba8347313300219fed5bfa52caf175298d860b61ff6069bb" +dependencies = [ + "bytes", + "heck", + "itertools", + "lazy_static", + "log", + "multimap", + "petgraph", + "prost", + "prost-types", + "regex", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7345d5f0e08c0536d7ac7229952590239e77abf0a0100a1b1d890add6ea96364" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dfaa718ad76a44b3415e6c4d53b17c8f99160dcb3a99b10470fce8ad43f6e3e" +dependencies = [ + "bytes", + "prost", +] + +[[package]] +name = "quote" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.8", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom 0.2.8", + "redox_syscall", + "thiserror", +] + +[[package]] +name = "ref-cast" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a733f1746c929b4913fe48f8697fcf9c55e3304ba251a79ffb41adfeaf49c2" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5887de4a01acafd221861463be6113e6e87275e79804e56779f4cdc131c60368" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] +name = "reqwest" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431949c384f4e2ae07605ccaa56d1d9d2ecdb5cadd4f9577ccfab29f2e5149fc" +dependencies = [ + "base64 0.13.0", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rmp" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44519172358fd6d58656c86ab8e7fbc9e1490c3e8f14d35ed78ca0dd07403c9f" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rmp-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b13be192e0220b8afb7222aa5813cb62cc269ebb5cac346ca6487681d2913e" +dependencies = [ + "byteorder", + "rmp", + "serde", +] + +[[package]] +name = "rocket" +version = "0.5.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ead083fce4a405feb349cf09abdf64471c6077f14e0ce59364aa90d4b99317" +dependencies = [ + "async-stream", + "async-trait", + "atomic", + "atty", + "binascii", + "bytes", + "either", + "figment", + "futures", + "indexmap", + "log", + "memchr", + "multer", + "num_cpus", + "parking_lot 0.12.1", + "pin-project-lite", + "rand", + "ref-cast", + "rocket_codegen", + "rocket_http", + "serde", + "serde_json", + "state", + "tempfile", + "time 0.3.15", + "tokio", + "tokio-stream", + "tokio-util", + "ubyte", + "version_check", + "yansi", +] + +[[package]] +name = "rocket_codegen" +version = "0.5.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aeb6bb9c61e9cd2c00d70ea267bf36f76a4cc615e5908b349c2f9d93999b47" +dependencies = [ + "devise", + "glob", + "indexmap", + "proc-macro2", + "quote", + "rocket_http", + "syn", + "unicode-xid", +] + +[[package]] +name = "rocket_http" +version = "0.5.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ded65d127954de3c12471630bf4b81a2792f065984461e65b91d0fdaafc17a2" +dependencies = [ + "cookie", + "either", + "futures", + "http", + "hyper", + "indexmap", + "log", + "memchr", + "pear", + "percent-encoding", + "pin-project-lite", + "ref-cast", + "serde", + "smallvec", + "stable-pattern", + "state", + "time 0.3.15", + "tokio", + "uncased", +] + +[[package]] +name = "rpassword" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "rsa" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c2603e2823634ab331437001b411b9ed11660fbc4066f3908c84a9439260d" +dependencies = [ + "byteorder", + "digest 0.9.0", + "lazy_static", + "num-bigint-dig", + "num-integer", + "num-iter", + "num-traits", + "pkcs1", + "pkcs8", + "rand", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-argon2" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50162d19404029c1ceca6f6980fe40d45c8b369f6f44446fa14bb39573b5bb9" +dependencies = [ + "base64 0.13.0", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.1", +] + +[[package]] +name = "rustls" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539a2bfe908f471bfa933876bd1eb6a19cf2176d375f82ef7f99530a40e48c2c" +dependencies = [ + "log", + "ring", + "sct", + "webpki", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0864aeff53f8c05aa08d86e5ef839d3dfcf07aeba2db32f12db0ef716e87bd55" +dependencies = [ + "base64 0.13.0", +] + +[[package]] +name = "rustversion" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97477e48b4cf8603ad5f7aaf897467cf42ab4218a38ef76fb14c2d6773a6d6a8" + +[[package]] +name = "ryu" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" + +[[package]] +name = "schannel" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" +dependencies = [ + "lazy_static", + "windows-sys 0.36.1", +] + +[[package]] +name = "scoped-tls" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "scratch" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "secp256k1" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7649a0b3ffb32636e60c7ce0d70511eda9c52c658cd0634e194d5a19943aeff" +dependencies = [ + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83080e2c2fc1006e625be82e5d1eb6a43b7fd9578b617fcc55814daf286bba4b" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bc1bb97804af6631813c55739f771071e0f2ed33ee20b68c86ec505d906356c" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728eb6351430bccb993660dfffc5a72f91ccc1295abaa8ce19b27ebe4f75568b" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_cbor_2" +version = "0.12.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46d75f449e01f1eddbe9b00f432d616fbbd899b809c837d0fbc380496a0dd55" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce777b7b150d76b9cf60d28b55f5847135a003f7d7350c6be7a773508ce7d45" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "184c643044780f7ceb59104cef98a5a6f12cb2288a7bc701ab93a362b49fd47d" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha-1" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.5", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.5", +] + +[[package]] +name = "sha2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.5", +] + +[[package]] +name = "sharded-slab" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "simple_asn1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time 0.3.15", +] + +[[package]] +name = "slab" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" +dependencies = [ + "autocfg 1.1.0", +] + +[[package]] +name = "smallvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" + +[[package]] +name = "socket2" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e2d2db9033d13a1567121ddd7a095ee144db4e1ca1b1bda3419bc0da294ebd" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6002a767bff9e83f8eeecf883ecb8011875a21ae8da43bffb817a57e78cc09" + +[[package]] +name = "spki" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c01a0c15da1b0b0e1494112e7af814a678fec9bd157881b49beac661e9b6f32" +dependencies = [ + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87e292b4291f154971a43c3774364e2cbcaec599d3f5bf6fa9d122885dbc38a" +dependencies = [ + "itertools", + "nom 7.1.1", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9249290c05928352f71c077cc44a464d880c63f26f7534728cca008e135c0428" +dependencies = [ + "sqlx-core", + "sqlx-macros", +] + +[[package]] +name = "sqlx-core" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbc16ddba161afc99e14d1713a453747a2b07fc097d2009f4c300ec99286105" +dependencies = [ + "ahash", + "atoi", + "base64 0.13.0", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "dirs", + "dotenvy", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-util", + "hashlink", + "hex", + "hkdf", + "hmac", + "indexmap", + "ipnetwork", + "itoa", + "libc", + "log", + "md-5", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rand", + "serde", + "serde_json", + "sha1", + "sha2", + "smallvec", + "sqlformat", + "sqlx-rt", + "stringprep", + "thiserror", + "tokio-stream", + "url", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-macros" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b850fa514dc11f2ee85be9d055c512aa866746adfacd1cb42d867d68e6a5b0d9" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-rt", + "syn", + "url", +] + +[[package]] +name = "sqlx-rt" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c5b2d25fa654cc5f841750b8e1cdedbe21189bf9a9382ee90bfa9dd3562396" +dependencies = [ + "native-tls", + "once_cell", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "stable-pattern" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" +dependencies = [ + "memchr", +] + +[[package]] +name = "state" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b" +dependencies = [ + "loom", +] + +[[package]] +name = "stringprep" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee348cb74b87454fff4b551cbf727025810a004f88aeacae7f85b87f4e9a1c1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "syn" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20518fe4a4c9acf048008599e464deb21beeae3d3578418951a189c235a7a9a8" + +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "tempfile" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +dependencies = [ + "cfg-if", + "fastrand", + "libc", + "redox_syscall", + "remove_dir_all", + "winapi", +] + +[[package]] +name = "termcolor" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "949517c0cf1bf4ee812e2e07e08ab448e3ae0d23472aee8a06c985f0c8815b16" + +[[package]] +name = "thiserror" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" +dependencies = [ + "once_cell", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + +[[package]] +name = "time" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d634a985c4d4238ec39cacaed2e7ae552fbd3c476b552c1deac3021b7d7eaf0c" +dependencies = [ + "itoa", + "libc", + "num_threads", + "time-macros", +] + +[[package]] +name = "time-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tokio" +version = "1.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e03c497dc955702ba729190dc4aac6f2a0ce97f913e5b1b5912fc5039d9099" +dependencies = [ + "autocfg 1.1.0", + "bytes", + "libc", + "memchr", + "mio", + "num_cpus", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "winapi", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +dependencies = [ + "rustls", + "tokio", + "webpki", +] + +[[package]] +name = "tokio-stream" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d660770404473ccd7bc9f8b28494a811bc18542b915c0855c51e8f419d5223ce" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb2e075f03b3d66d8d8785356224ba688d2906a371015e225beeb65ca92c740" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" +dependencies = [ + "serde", +] + +[[package]] +name = "tonic" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55b9af819e54b8f33d453655bef9b9acc171568fb49523078d0cc4e7484200ec" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.13.0", + "bytes", + "flate2", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "prost-derive", + "rustls-native-certs", + "rustls-pemfile", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "tracing-futures", +] + +[[package]] +name = "tonic-build" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c6fd7c2581e36d63388a9e04c350c21beb7a8b059580b2e93993c526899ddc" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c530c8675c1dbf98facee631536fa116b5fb6382d7dd6dc1b118d970eafe3ba" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4017f8f45139870ca7e672686113917c71c7a6e02d4924eda67186083c03081a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" +dependencies = [ + "lazy_static", + "log", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6176eae26dd70d0c919749377897b54a9276bd7061339665dd68777926b5a70" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + +[[package]] +name = "ubyte" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c81f0dae7d286ad0d9366d7679a77934cfc3cf3a8d67e82669794412b2368fe6" +dependencies = [ + "serde", +] + +[[package]] +name = "uncased" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b01702b0fd0b3fadcf98e098780badda8742d4f4a7676615cad90e8ac73622" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" + +[[package]] +name = "unicode-ident" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fdbf052a0783de01e944a6ce7a8cb939e295b1e7be835a1112c3b9a7f047a5a" + +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + +[[package]] +name = "unicode-xid" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "universal-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d3160b73c9a19f7e2939a2fdad446c57c1bbbbf4d919d3213ff1267a580d8b5" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "uuid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feb41e78f93363bb2df8b0e86a2ca30eed7806ea16ea0c790d757cf93f79be83" +dependencies = [ + "getrandom 0.2.8", + "serde", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaf9f5aceeec8be17c128b2e93e031fb8a4d469bb9c4ae2d7dc1888b26887268" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8ffb332579b0557b52d268b91feab8df3615f265d5270fec2a8c95b17c1142" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23639446165ca5a5de86ae1d8896b737ae80319560fbaa4c2887b7da6e7ebd7d" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052be0f94026e6cbc75cdefc9bae13fd6052cdcaf532fa6c45e7ae33a1e6c810" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c38c045535d93ec4f0b4defec448e4291638ee608530863b1e2ba115d4fff7f" + +[[package]] +name = "web-sys" +version = "0.3.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcda906d8be16e728fd5adc5b729afad4e444e106ab28cd1c7256e54fa61510f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webauthn-authenticator-rs" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d30dcdffd0c5dfa110246701399efcc09962c1bb565f61a5d7fe995645ff6f21" +dependencies = [ + "base64urlsafedata", + "nom 7.1.1", + "openssl", + "rpassword", + "serde", + "serde_cbor_2", + "serde_json", + "tracing", + "url", + "webauthn-rs-proto", +] + +[[package]] +name = "webauthn-rs" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5984278a28dc397c565fd79ee1aba67b74fa365c4eea489f7258b3f902422f" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6528b4769d8fbe020e0e8c2e66bab6035982a2e9c3f8dac384120718f4763f4" +dependencies = [ + "base64 0.13.0", + "base64urlsafedata", + "compact_jwt", + "der-parser", + "nom 7.1.1", + "openssl", + "rand", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror", + "tracing", + "url", + "uuid", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e9a265574f8d7b8f8c94c4488bb491a82d7b8e183003a4512d3dceb88efd98" +dependencies = [ + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "webpki" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "which" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c831fbbee9e129a8cf93e7747a82da9d95ba8e16621cae60ec2cdc849bacb7b" +dependencies = [ + "either", + "libc", + "once_cell", +] + +[[package]] +name = "whoami" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6631b6a2fd59b1841b622e8f1a7ad241ef0a46f2d580464ce8140ac94cbd571" +dependencies = [ + "bumpalo", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbedf6db9096bc2364adce0ae0aa636dcd89f3c3f2cd67947062aaf0ca2a10ec" +dependencies = [ + "windows_aarch64_msvc 0.32.0", + "windows_i686_gnu 0.32.0", + "windows_i686_msvc 0.32.0", + "windows_x86_64_gnu 0.32.0", + "windows_x86_64_msvc 0.32.0", +] + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc 0.42.0", + "windows_i686_gnu 0.42.0", + "windows_i686_msvc 0.42.0", + "windows_x86_64_gnu 0.42.0", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc 0.42.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e92753b1c443191654ec532f14c199742964a061be25d77d7a96f09db20bf5" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" + +[[package]] +name = "windows_i686_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a711c68811799e017b6038e0922cb27a5e2f43a2ddb609fe0b6f3eeda9de615" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" + +[[package]] +name = "windows_i686_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c11bb1a02615db74680b32a68e2d61f553cc24c4eb5b4ca10311740e44172" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c912b12f7454c6620635bbff3450962753834be2a594819bd5e945af18ec64bc" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504a2476202769977a040c6364301a3f65d0cc9e3fb08600b2bda150a0488316" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "x25519-dalek" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2392b6b94a576b4e2bf3c5b2757d63f10ada8020a2e4d08ac849ebcf6ea8e077" +dependencies = [ + "curve25519-dalek", + "rand_core 0.5.1", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9bace5b5589ffead1afb76e43e34cff39cd0f3ce7e170ae0c29e53b88eb1c" +dependencies = [ + "asn1-rs", + "base64 0.13.0", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.1", + "oid-registry", + "rusticata-macros", + "thiserror", + "time 0.3.15", +] + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + +[[package]] +name = "zeroize" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4756f7db3f7b5574938c3eb1c117038b8e07f95ee6718c0efad4ac21508f1efd" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f8f187641dad4f680d25c4bfc4225b418165984179f26ca76ec4fb6441d3a17" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..54088a958 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,84 @@ +[package] +name = "defguard" +version = "0.3.0" +edition = "2021" + +[workspace] + +[dependencies] +model_derive = { path = "model-derive" } +argon2 = { version = "0.4", features = ["std"] } +base64 = { version = "0.13" } +bincode = "1.3" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "3.2", features = ["derive", "env"] } +fern = { version = "0.6", features = ["colored"] } +# match ipnetwork version from sqlx-core +ipnetwork = { version = "0.19", features = ["serde"] } +jsonwebtoken = "8.1" +ldap3 = "0.10" +log = "0.4" +md4 = "0.10" +otpauth = "0.4" +openidconnect = { version = "2.3", default-features = false, optional = true } +oxide-auth = { version = "0.5", optional = true } +oxide-auth-async = { version = "0.1", optional = true } +prost = "0.11" +rand = "0.8" +rand_core = { version = "0.5", default-features = false, features = [ + "getrandom", +] } +reqwest = { version = "0.11", features = ["json"] } +rocket = { version = "0.5.0-rc.2", features = ["json"] } +rsa = "*" +secp256k1 = { version = "0.24", features = ["recovery"] } +serde = { version = "1.0", features = ["derive"] } +# match version from webauthn-rs-core +serde_cbor = { version = "0.12.0-dev", package = "serde_cbor_2" } +serde_urlencoded = "0.7" +sha-1 = "0.10" +sqlx = { version = "0.6", features = [ + "chrono", + "ipnetwork", + "offline", + "runtime-tokio-native-tls", + "postgres", + "uuid", +] } +tiny-keccak = { version = "2.0", features = ["keccak"] } +tokio = { version = "1", features = [ + "macros", + "rt", + "rt-multi-thread", + "sync", + "time", +] } +tokio-stream = "0.1" +tonic = { version = "0.8", features = ["gzip", "tls", "tls-roots"] } +uuid = { version = "1.1", features = ["v4"] } +webauthn-rs = { version = "0.4", features = [ + "danger-allow-state-serialisation", +] } +x25519-dalek = { version = "1.2" } + +[dev-dependencies] +matches = "0.1" +webauthn-authenticator-rs = { version = "0.4" } + +[build-dependencies] +tonic-build = "0.8" + +[features] +default = ["openid", "wireguard", "worker"] +mock-license-key = [] +oauth = ["dep:oxide-auth", "dep:oxide-auth-async"] +openid = ["oauth", "dep:openidconnect"] +worker = [] +wireguard = [] + +[profile.dev.package.sqlx-macros] +opt-level = 2 + +[profile.release] +lto = "thin" +strip = "debuginfo" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..e36f0c683 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM rust:latest as chef + +WORKDIR /build + +# install & cache necessary components +RUN cargo install cargo-chef +RUN rustup component add rustfmt + +FROM chef as planner +# prepare recipe +COPY Cargo.toml Cargo.lock ./ +COPY src src +COPY model-derive model-derive +COPY proto proto +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +# build deps from recipe & cache as docker layer +COPY --from=planner /build/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json +RUN cargo install sqlx-cli + +# build project +RUN apt-get update && apt-get -y install protobuf-compiler libprotobuf-dev +COPY Cargo.toml Cargo.lock build.rs sqlx-data.json ./ +COPY src src +COPY model-derive model-derive +COPY proto proto +COPY migrations migrations +ENV SQLX_OFFLINE true +RUN cargo install --locked --path . --root /build + +# run +FROM debian:bullseye-slim as runtime +RUN apt-get update -y && \ + apt-get install --no-install-recommends -y ca-certificates && \ + rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=builder /build/bin/defguard . +ENTRYPOINT ["./defguard"] diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 000000000..f9385259a --- /dev/null +++ b/Dockerfile.ci @@ -0,0 +1,7 @@ +FROM debian:bullseye-slim +RUN apt-get update -y && \ + apt-get install --no-install-recommends -y ca-certificates && \ + rm -rf /var/lib/apt/lists/* +COPY build/bin/defguard . +USER 1000 +ENTRYPOINT ["./defguard"] diff --git a/Dockerfile.device b/Dockerfile.device new file mode 100644 index 000000000..9748718f6 --- /dev/null +++ b/Dockerfile.device @@ -0,0 +1,12 @@ +FROM alpine:3 + +RUN apk add wireguard-tools && echo wireguard >> /etc/modules +RUN printf "[Interface]\n\ +PrivateKey = wGS1qdJfYbWJsOUuP1IDgaJYpR+VaKZPVZvdmLjsH2Y=\n\ +Address = 10.1.1.10\n\ +[Peer]\n\ +PublicKey = zGMeVGm9HV9I4wSKF9AXmYnnAIhDySyqLMuKpcfIaQo=\n\ +AllowedIPs = 10.1.1.0/24\n\ +Endpoint = gateway:50051" > /etc/wireguard/defguard.conf +CMD wg-quick up defguard && ping -s 2000 10.1.1.1 + diff --git a/Dockerfile.ldap b/Dockerfile.ldap new file mode 100644 index 000000000..5086b4002 --- /dev/null +++ b/Dockerfile.ldap @@ -0,0 +1,3 @@ +FROM osixia/openldap:1.5.0 as runtime +# load ldifs +COPY ./ldif /container/service/slapd/assets/config/bootstrap/ldif/custom diff --git a/Openid.md b/Openid.md new file mode 100644 index 000000000..c2de8f585 --- /dev/null +++ b/Openid.md @@ -0,0 +1,84 @@ +# How to setup OpenID client for login with defguard. + +### Client creation + +Add new client as admin on defguard OpenID Apps tab. Remember to add correct Redirect Uri(User will be redirected to this page with generated code). + +### Authentication request + +Set up your login with defguard button to redirect to authorization endpoint + +``` +http://defguard.teonite.net/openid/authorize? +client_id= // Generated by defguard available on app detail page +&redirect_uri= // Generated by defguard available on app detail page +&scope=openid profile phone email +&response_type=code +&state= +``` + +#### Note: + +1. Client id and secret is generated by defguard after creating your app you can see it on app detail page +2. Scope must contain openid +3. Available scopes are profile(all available info from user profile) phone and email +4. Currently only supported response_type is code. +5. Redirect uri is url on which user will be redirected with generated pkce code (Redirect uri must match uri declared on client creation otherwise error will be returned) + +#### Successful authenication response + +``` +HTTP/1.1 302 Found + +Location: ? +code=SplxlOBeZQQYbYS6WxSbIA +&state=af0ifjsldkj +``` + +### Exchange code for token + +After receiving code from previous step you need to exchange it for token +on token endpoint defguard.tnt/api/v1/openid/token + +Request Header and Url: + +``` +Content-Type: application/x-www-form-urlencoded +POST defguard.tnt/api/v1/openid/token +``` + +Request body: +Need to be form encoded +``` +grant_type=authorization_code +&redirect_uri= +&code= +``` + +#### Note: + +1. Currently only supported grant_type is authorization_code +2. Code is your pkce code received in previous step + +#### Successful Token Response + +``` + HTTP/1.1 200 OK + Content-Type: application/json + Cache-Control: no-store + Pragma: no-cache + + { + "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkazcifQ.ewogImlzc + yI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4Mjg5 + NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAibi0wUzZ + fV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEzMTEyODA5Nz + AKfQ.ggW8hZ1EuVLuxNuuIJKX_V8a_OMXzR0EHR9R6jgdqrOOF4daGU96Sr_P6q + Jp6IcmD3HP99Obi1PRs-cwh3LO-p146waJ8IhehcwL7F09JdijmBqkvPeB2T9CJ + NqeGpe-gccMg4vfKjkM8FcGvnzZUN4_KSP0aAp1tOJ1zZwgjxqGByKHiOtX7Tpd + QyHE5lcMiKPXfEIQILVq0pc_E2DzL7emopWoaoZTF_m0_N0YzFC6g6EJbOEoRoS + K5hoDalrcvRYLSrQAZZKflyuVCyixEoV9GfNQC3_osjzw2PAithfubEEBLuVVk4 + XUVrWOLrLl0nx7RkKU8NXNHq-rvKMzqg" + } +``` + diff --git a/README.md b/README.md new file mode 100644 index 000000000..014c6457d --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +

+ defguard +

+ +Defguard is an open-source identity management system made with the aim to make company managment as easy as possible. + +**Problems that DefGuard adresses and solves** + +* Your company has a self-hosted services, e.g.: Git/Jira/Slack etc. and wants an easy-to-use central logging system with simple and nice UI +* Fast and easy to setup VPN (Remote access to company resources as above) +* Webhooks triggered after taking specified actions on user sending user data to provided URL which allows you to automate stuff like sending welcome mail or creating accounts in different services + +**Features:** + +* Wireguard VPN management +* Webhooks +* LDAP synchronization +* Yubikey Provisioning +* OpenID connect provider + +

+ defguard +

+ +See the [documentation](https://neovim.io/doc/general/) for more information. + +* [Introduction to DefGuard](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/) +* Community features + * [Deploying your instance](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/community-features/setting-up-your-instance) + * [Webhooks](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/community-features/webhooks) + * [WireGuard](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/community-features/wireguard) +* Enterprise features + * [LDAP synchronization](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/enterprise-features/ldap-synchronization-setup) + * [OpenID Connect](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/enterprise-features/openid-connect) + * [YubiKey Provisioning](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/enterprise-features/yubikey-provisioning) +* In depth + * [Architecture overview](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/in-depth/architecture) + * [Configuration](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/in-depth/environmental-variables-configuration) + * [WireGuard VPN](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/in-depth/wireguard-vpn) +* For developers + * [Contributing](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/for-developers/contributing) + * [Other resources](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/for-developers/other-resources) +* Extras + * [Support](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/extras/support) + * [Troubleshooting](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/extras/troubelshooting) + * [FAQ](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/extras/faq) + * [Roadmap](https://app.gitbook.com/o/MDuF1T1ZyTda6cRc7AKP/s/xuW7w9EJzjxdg83zu6CW/~/changes/ZqwZls3bz3uhQ9Vh8O9m/extras/roadmap) + +# Development environment setup + +Remember to clone DefGuard repository recursively (with protos): + +``` +git clone --recursive git@git.teonite.net:orion/core.git +``` + +## With Docker Compose + +Using Docker Compose you can setup a simple stack with: + +* backend +* database (PostgreSQL) +* VPN gateway +* device connected to the gateway + +This way you'll have some live stats data to work with. + +To do so follow these steps: + +1. Migrate database and insert test network and device: + +``` +docker compose run core init-dev-env +``` + +2. Run the application: + +``` +docker compose up +``` + +## Cargo + +To run backend without Docker, you'll need: + +* PostgreSQL database +* environment variables set + +Run PostgreSQL with: + +``` +docker compose up -d db +``` + +You'll find environment variables in .env file. Source them however you like (we recommend https://direnv.net/). +Once that's done, you can run backend with: + +``` +cargo run +``` diff --git a/build.rs b/build.rs new file mode 100644 index 000000000..43049e287 --- /dev/null +++ b/build.rs @@ -0,0 +1,14 @@ +fn main() -> Result<(), Box> { + tonic_build::configure().compile( + &[ + "proto/core/auth.proto", + "proto/core/vpn.proto", + "proto/worker/worker.proto", + "proto/wireguard/gateway.proto", + ], + &["proto/core", "proto/worker", "proto/wireguard"], + )?; + println!("cargo:rerun-if-changed=proto"); + println!("cargo:rerun-if-changed=migrations"); + Ok(()) +} diff --git a/docker-compose.ldap.yaml b/docker-compose.ldap.yaml new file mode 100644 index 000000000..500b1bc48 --- /dev/null +++ b/docker-compose.ldap.yaml @@ -0,0 +1,65 @@ +version: "3" + +services: + core: + build: + context: . + dockerfile: Dockerfile + environment: + DEFGUARD_JWT_SECRET: orion-secret + DEFGUARD_LDAP_URL: ldap://openldap:1389 + DEFGUARD_LDAP_SERVICE_PASSWORD: adminpassword + DEFGUARD_LDAP_USER_SEARCH_BASE: "ou=users,dc=example,dc=org" + DEFGUARD_LDAP_GROUP_SEARCH_BASE: "ou=groups,dc=example,dc=org" + DEFGUARD_DB_HOST: db + DEFGUARD_DB_PORT: 5432 + DEFGUARD_DB_USER: defguard + DEFGUARD_DB_PASSWORD: defguard + DEFGUARD_DB_NAME: defguard + ports: + # rest api + - "8000:8000" + # grpc + - "50055:50055" + + gateway: + image: registry.teonite.net/defguard/wireguard:latest + environment: + DEFGUARD_GRPC_URL: https://core:50055 + DEFGUARD_STATS_PERIOD: 60 + DEFGUARD_TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJEZWZHdWFyZCIsInN1YiI6Im1vZGlmaWVkIiwiY2xpZW50X2lkIjoiIiwiZXhwIjo1OTQ5MDM0NTcxLCJuYmYiOjE2NTQwNjcyNzYsInJvbGVzIjpbXX0.Z8re0LmnE3xeL4z30CvFsCD2ETAkXIOpCfq1Q4axl3w + RUST_LOG: debug + ports: + # wireguard endpoint + - "50051:50051/udp" + depends_on: + - core + cap_add: + - NET_ADMIN + + openldap: + image: bitnami/openldap:2.6 + environment: + LDAP_EXTRA_SCHEMAS: "cosine,inetorgperson,nis,openssh-lpk_openldap,samba,gnupg-ldap-schema,orion" + ports: + - "389:1389" + volumes: + - ./ldap-initdb.d:/docker-entrypoint-initdb.d:ro + - ./ldif/gnupg-ldap-schema.ldif:/opt/bitnami/openldap/etc/schema/gnupg-ldap-schema.ldif:ro + - ./ldif/openssh-lpk_openldap.ldif:/opt/bitnami/openldap/etc/schema/openssh-lpk_openldap.ldif:ro + - ./ldif/orion.ldif:/opt/bitnami/openldap/etc/schema/orion.ldif:ro + - ./ldif/samba.ldif:/opt/bitnami/openldap/etc/schema/samba.ldif:ro + - ./ldif/init.ldif:/ldifs/init.ldif:ro + - ./ldif/custom.ldif:/schema/custom.ldif:ro + - ./data:/bitnami/openldap + + db: + image: postgres:14-alpine + environment: + POSTGRES_DB: defguard + POSTGRES_USER: defguard + POSTGRES_PASSWORD: defguard + volumes: + - ./.volumes/db:/var/lib/postgresql/data + ports: + - "5432:5432" diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..3c61501c0 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,56 @@ +version: "3" + +services: + core: + build: + context: . + dockerfile: Dockerfile + environment: + DEFGUARD_JWT_SECRET: orion-secret + DEFGUARD_DB_HOST: db + DEFGUARD_DB_PORT: 5432 + DEFGUARD_DB_USER: defguard + DEFGUARD_DB_PASSWORD: defguard + DEFGUARD_DB_NAME: defguard + RUST_BACKTRACE: 1 + ports: + # rest api + - "8000:8000" + # grpc + - "50055:50055" + depends_on: + - db + + gateway: + image: registry.teonite.net/defguard/wireguard:latest + environment: + DEFGUARD_GRPC_URL: http://core:50055 + DEFGUARD_STATS_PERIOD: 60 + DEFGUARD_TOKEN: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJEZWZHdWFyZCIsInN1YiI6IlRlc3ROZXQiLCJjbGllbnRfaWQiOiIiLCJleHAiOjU5NjA1MzQ1NjgsIm5iZiI6MTY2NTU2NzI3Mywicm9sZXMiOltdfQ.NjZ0L5jvrkLuCgjuMU3vUQi1tR1GTAfj5ZN9IfRgUPI + RUST_LOG: debug + ports: + # wireguard endpoint + - "50051:50051/udp" + depends_on: + - core + cap_add: + - NET_ADMIN + + db: + image: postgres:14-alpine + environment: + POSTGRES_DB: defguard + POSTGRES_USER: defguard + POSTGRES_PASSWORD: defguard + volumes: + - ./.volumes/db:/var/lib/postgresql/data + ports: + - "5432:5432" + + device: + build: + dockerfile: Dockerfile.device + depends_on: + - gateway + cap_add: + - NET_ADMIN diff --git a/docs/header.png b/docs/header.png new file mode 100644 index 0000000000000000000000000000000000000000..6e4d1b9d23f3b93d17bc2bd16d07bdc6a65e5efa GIT binary patch literal 4445 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

KL_7$%aH6Ul&!YK)J|!>47ftg5;*fQGeb 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

kFFkr}I^* zyQ@PK>vCJwf;tHY(P*bX^N+H?bkkF0B&K$p6UJ9yRp#`3H1!Go%j2mF^2{8|W#-_b zba-+{_uVeWBzW~gL23AK_yNE-iqTCMfxu5KLJ%pq79x@&LWK2@KpG1R>HNe!_e>IW zTM|F?_IyPx^vDBN)N7rsW^gRmv^x;#hcNp;3TL<`4UT+!?gNuH!6E&k0Rf{J#jp7_ zD-ySMl|65*v&~d}Xr=`;#n*WN3ZH4Ybexy7sx%Qiy1P^#0pisd?j1c*+WETKGyvKk zaiTq40SdO;S-fFeFUVkF^_+j2vPN%8tW_~2NbB(;)ECFzbEkh*rICq%WaNyqs4?$P z`h1Mol~g2%p#8OR!7Oct5vKN;P1+Rrd2@(Hd4h$rUh-k%f=f5@-G0w3_u~mMbTZE0 zEdA-S1ljlaSZT_jEY`~+>tFkiW~_H9?GvC(;NN?sOb0?jx;Kye1q!@Mi)1_ zc{ye-P9U?DS*Kg^lv_ zGtJnU8M0&xl9L5|5`E!&-HxB*gwQEmksG_a$}-A4G5;NyZ0lnv6J?&i;3Fn&TaMq0 z|39qxo#3rze$s8d?OnyrGej(7=Gq#PFCxOMY8pBZFyyD-X+HL9fQYCy7pbe{2nghd{EKLw5H?d4l9L% znR-0k!h7`@SN{Xjv9K@nsqmV~Jx+zS)T@?^J&74F%J`g|nC(~0rSATV&G0tmZMceFlfUCL7ew3cSE_`axAmM%lxXv9{L{rD?&zJj zdCK^j*E=H---L0)uD#=?#zvJN6iPEc#`V973He5p4Y3N5t{ykIv#C}>{$A7nLq>lM zy7?RL+(=GqwB+V+wNT%zpW*alR2bILN}b)-AOKPX0xwkq@PP1JZfQa$o1-Pw>v$PFPaTXh2k z(+e%56bY312^0&PF)76HrqAA-()1@WI8T#eN^f3#a7*xyrB$+JtycnD>{$tXG4?N- ziF^qb}> z#IylFS+}|JGY>J^_mC#SX+DGv_Bi-s!?<`ZoSr#c%_;%%0iZrz*<|ZREnNEaXIN#Q zR^zq$yjNXBBA!eBk^yP2d#i@#Pii|&sHe9gwwQ{){!cca(kgdt`HQt>I04!F`dRF5 zLl#=_HB_)7hVZWcwkqzmM=vVjJk;q`f!lB#kFgWe2kz*N~|+MroWx6-l$_H+qeJ-nP~{ zzKc1aSY9Jdqx7;f(&RIB0f)8@9!CJbJP&N5IW7-~h}ZY*K8;oXg6njX-hH+MC0# z3r@)yaSG32VLk7b$3X?ZXpE+1(3TsKi7{$ncWtsy}Fi`;rM{LSR55=jQ z^DKidsgcOZaRJ0;L81(&zFL+fK*}1^0_XL;W|D|4zjCI43_0^7iFJGk`1umf{&i@+ z-j@+jdY-E=`Ucm~LEikfyOWLccVLEl6~r<7DZ+{0OIr9h+)d;~Hn>GrzP`b$9?ybh z?khoi!KH2By?yZ7U4Zo%QHS5b8>ZY7v>=Br|GypAKigw?J$EHvS7)z~P(L1kO`uxi zaF7p}YLE8QqBe(+Ob_&>k=IyvjOWUm0@N=pqRdP+$2G2W?HE&RIx+Z?CFFR7R?4JKhjw@_8WqTnSwSj zi6{a+?XvLTDYLeY!8gq!MPhp~Q^HxBbP2O**|;s{U#gxX*iIYnb4VTCj0 zmQ0MUOK)qUr#PPjleatJJoi6s$bZH?4FNs zXsT0(5wwyJDAdrT44bQ*!#LhV5U_yM+*m(+FN1jKZ_PJToTKcA+@ub6>Ee!N1EEY; z_^?m(Is7P9`Wrv_GovW4!d%-Rtj9%-kvfm|J@`f5no98Mm*3|I?9=W^8{))+{46`! z4@EgWSH9ETrFdLFd=BqC-UaQSM~u|hw%B)CPP%vrU4LNinYmtlpb5>;dE_iU6hFc6 z^IP*dW@H&4zuP3UB;^+|_^evEK1D{UZ%KI2G;WZmD%@WJXF&G?%L<9U5dQVbf;)38 zIejD}%Y@Kt>CAiSH`&&V`eVZ(=vp5hx)-j-fxlC|CP-jTY0-+zJ4F1b1(AL6y5X)R z9<7%7F(~JnZ}+o!sL}f3NdgEUs$U}o_sv(XG4oLE0)$oHAizJU1On1AZA9o-3{;H( zF&9OS)ye125*(qv)ish8D_0UkjmwT};h7@q;u6R6c)Oj^k zg8=q_+^w1p-OQ?dUw_ypl7W5y9B1k5>R{0WZbCBFHPG8Tmc6zl(!LCtKXTjWz;p=t zR^QrL`L9AF2uwBjVz3}H&T@Y-IQ{L4y;%Ad+a!O{`=*A=PYSJ`t>>;!->dF-zv(

1IFo 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 Ctmz2= literal 0 HcmV?d00001 diff --git a/k8s/base/config.env b/k8s/base/config.env new file mode 100644 index 000000000..9f5bdf989 --- /dev/null +++ b/k8s/base/config.env @@ -0,0 +1,5 @@ +DEFGUARD_DB_HOST=db +DEFGUARD_DB_USER=defguard +DEFGUARD_DB_NAME=defguard +DEFGUARD_LDAP_URL=ldap://oldap:389 +DEFGUARD_WG_SERVICE_URL=http://wireguard:50051 diff --git a/k8s/base/core-deployment.yaml b/k8s/base/core-deployment.yaml new file mode 100644 index 000000000..a58f3fb46 --- /dev/null +++ b/k8s/base/core-deployment.yaml @@ -0,0 +1,41 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: core + labels: + app: orion + service: core +spec: + strategy: + type: Recreate + replicas: 1 + selector: + matchLabels: + app: orion + service: core + template: + metadata: + labels: + app: orion + service: core + spec: + containers: + - name: core + image: registry.teonite.net/defguard/core:latest + imagePullPolicy: Always + envFrom: + - configMapRef: + name: web + - secretRef: + name: web + - secretRef: + name: db-password + ports: + - name: http + containerPort: 8000 + - name: grpc + containerPort: 50055 + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 diff --git a/k8s/base/core-ingress.yaml b/k8s/base/core-ingress.yaml new file mode 100644 index 000000000..1008ef8e9 --- /dev/null +++ b/k8s/base/core-ingress.yaml @@ -0,0 +1,18 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + ingress.kubernetes.io/protocol: h2c + name: orion-grpc-ingress +spec: + rules: + - host: defguard-grpc.tnt + http: + paths: + - path: / + pathType: ImplementationSpecific + backend: + service: + name: core-grpc + port: + name: grpc diff --git a/k8s/base/core-service-grpc.yaml b/k8s/base/core-service-grpc.yaml new file mode 100644 index 000000000..ce6834c83 --- /dev/null +++ b/k8s/base/core-service-grpc.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + traefik.ingress.kubernetes.io/service.serversscheme: h2c + name: core-grpc + labels: + app: orion + service: core +spec: + ports: + - name: grpc + port: 50055 + targetPort: "grpc" + protocol: TCP + selector: + app: orion + service: core diff --git a/k8s/base/core-service.yaml b/k8s/base/core-service.yaml new file mode 100644 index 000000000..c1ac85b65 --- /dev/null +++ b/k8s/base/core-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: core + labels: + app: orion + service: core +spec: + ports: + - name: http + port: 8000 + targetPort: "http" + protocol: TCP + selector: + app: orion + service: core diff --git a/k8s/base/db-deployment.yaml b/k8s/base/db-deployment.yaml new file mode 100644 index 000000000..bb0b8145d --- /dev/null +++ b/k8s/base/db-deployment.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: orion + service: db + name: db +spec: + replicas: 1 + selector: + matchLabels: + app: orion + service: db + strategy: + type: Recreate + template: + metadata: + labels: + app: orion + service: db + spec: + containers: + - name: db + image: postgres:14-alpine + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: web + key: DEFGUARD_DB_NAME + - name: POSTGRES_USER + valueFrom: + configMapKeyRef: + name: web + key: DEFGUARD_DB_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: db-password + key: DEFGUARD_DB_PASSWORD + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: db-claim + subPath: postgres + restartPolicy: Always + volumes: + - name: db-claim + persistentVolumeClaim: + claimName: db-claim diff --git a/k8s/base/db-persistentvolumeclaim.yaml b/k8s/base/db-persistentvolumeclaim.yaml new file mode 100644 index 000000000..54b1d250e --- /dev/null +++ b/k8s/base/db-persistentvolumeclaim.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: db-claim +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/k8s/base/db-service.yaml b/k8s/base/db-service.yaml new file mode 100644 index 000000000..14c039e87 --- /dev/null +++ b/k8s/base/db-service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: orion + service: db + name: db +spec: + ports: + - name: "db" + port: 5432 + targetPort: 5432 + selector: + app: orion + service: db diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 000000000..0e0cd6a55 --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,11 @@ +resources: + - core-service.yaml + - core-deployment.yaml + - core-ingress.yaml + - db-deployment.yaml + - db-service.yaml + - db-persistentvolumeclaim.yaml +configMapGenerator: + - name: web + envs: + - config.env diff --git a/k8s/overlays/dev/kustomization.yaml b/k8s/overlays/dev/kustomization.yaml new file mode 100644 index 000000000..bd106ab9a --- /dev/null +++ b/k8s/overlays/dev/kustomization.yaml @@ -0,0 +1,5 @@ +resources: + - ../../base/ + - ldap-service.yaml + - ldap-deployment.yaml + - ldap-storage.yaml diff --git a/k8s/overlays/dev/ldap-deployment.yaml b/k8s/overlays/dev/ldap-deployment.yaml new file mode 100644 index 000000000..630ca28e8 --- /dev/null +++ b/k8s/overlays/dev/ldap-deployment.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: oldap + labels: + app: orion + service: oldap +spec: + replicas: 1 + selector: + matchLabels: + app: orion + service: oldap + strategy: + type: Recreate + template: + metadata: + labels: + app: orion + service: oldap + spec: + containers: + - name: oldap + image: registry.teonite.net/defguard/ldap:latest + imagePullPolicy: Always + ports: + - name: ldap + containerPort: 389 + envFrom: + - secretRef: + name: ldap + volumeMounts: + - mountPath: /var/lib/ldap + name: ldap-storage + subPath: ldap + - mountPath: /etc/ldap/slapd.d + subPath: slapd.d + name: ldap-storage + volumes: + - name: ldap-storage + persistentVolumeClaim: + claimName: ldap-storage diff --git a/k8s/overlays/dev/ldap-service.yaml b/k8s/overlays/dev/ldap-service.yaml new file mode 100644 index 000000000..a543d9408 --- /dev/null +++ b/k8s/overlays/dev/ldap-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: oldap + labels: + app: orion + service: oldap +spec: + ports: + - name: ldap + port: 389 + targetPort: "ldap" + protocol: TCP + selector: + app: orion + service: oldap diff --git a/k8s/overlays/dev/ldap-storage.yaml b/k8s/overlays/dev/ldap-storage.yaml new file mode 100644 index 000000000..618137a05 --- /dev/null +++ b/k8s/overlays/dev/ldap-storage.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ldap-storage +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/k8s/overlays/prod/config.env b/k8s/overlays/prod/config.env new file mode 100644 index 000000000..6be5128b3 --- /dev/null +++ b/k8s/overlays/prod/config.env @@ -0,0 +1,4 @@ +DEFGUARD_LDAP_USER_SEARCH_BASE=ou=workers,dc=teonite,dc=com +DEFGUARD_LDAP_GROUP_SEARCH_BASE=ou=groups,dc=teonite,dc=com +DEFGUARD_LDAP_ADMIN_GROUP=cn=admin,ou=groups,dc=teonite,dc=com +DEFGUARD_LDAP_URL=ldap://hq.teonite.net:389 diff --git a/k8s/overlays/prod/ingress-patch.json b/k8s/overlays/prod/ingress-patch.json new file mode 100644 index 000000000..6a4823e3c --- /dev/null +++ b/k8s/overlays/prod/ingress-patch.json @@ -0,0 +1,7 @@ +[ + { + "op": "replace", + "path": "/spec/rules/0/host", + "value": "defguard-grpc.teonite.net" + } +] diff --git a/k8s/overlays/prod/kustomization.yaml b/k8s/overlays/prod/kustomization.yaml new file mode 100644 index 000000000..73a8c68d2 --- /dev/null +++ b/k8s/overlays/prod/kustomization.yaml @@ -0,0 +1,12 @@ +resources: + - ../../base/ +patches: + - target: + kind: Ingress + name: orion-grpc-ingress + path: ingress-patch.json +configMapGenerator: + - name: web + behavior: merge + envs: + - config.env diff --git a/ldap-initdb.d/set_access.sh b/ldap-initdb.d/set_access.sh new file mode 100755 index 000000000..b499f9946 --- /dev/null +++ b/ldap-initdb.d/set_access.sh @@ -0,0 +1,18 @@ +. /opt/bitnami/scripts/libopenldap.sh + +ldap_start_bg + +echo "Setting custom access permissions for ${LDAP_ROOT}" + +cat <*) +# mailsub: (pgpUserID=*<*%s*>*) +# mailend: (pgpUserID=*<*%s>*) +olcAttributeTypes: {8}( + 1.3.6.1.4.1.3401.8.2.16 + NAME 'pgpUserID' + DESC 'User ID(s) associated with the key' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +# The creation time of the primary key. +# Stored in ISO format: "20201231 120000" +olcAttributeTypes: {9}( + 1.3.6.1.4.1.3401.8.2.17 + NAME 'pgpKeyCreateTime' + DESC 'Primary key creation time' + EQUALITY caseIgnoreMatch + ORDERING caseIgnoreOrderingMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) +# Not used +olcAttributeTypes: {10}( + 1.3.6.1.4.1.3401.8.2.18 + NAME 'pgpSignerID' + DESC 'pgpSignerID attribute for PGP' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +# A value of 1 indicated that the keyblock has been revoked +olcAttributeTypes: {11}( + 1.3.6.1.4.1.3401.8.2.19 + NAME 'pgpRevoked' + DESC 'pgpRevoked attribute for PGP' + EQUALITY caseIgnoreMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) +olcAttributeTypes: {12}( + 1.3.6.1.4.1.3401.8.2.20 + NAME 'pgpSubKeyID' + DESC 'Sub-key ID(s) of the PGP key.' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +# A hin on the keysize. +olcAttributeTypes: {13}( + 1.3.6.1.4.1.3401.8.2.21 + NAME 'pgpKeySize' + DESC 'pgpKeySize attribute for PGP' + EQUALITY caseIgnoreMatch + ORDERING caseIgnoreOrderingMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +# Expiration time of the primary key. +# Stored in ISO format: "20201231 120000" +olcAttributeTypes: {14}( + 1.3.6.1.4.1.3401.8.2.22 + NAME 'pgpKeyExpireTime' + DESC 'pgpKeyExpireTime attribute for PGP' + EQUALITY caseIgnoreMatch + ORDERING caseIgnoreOrderingMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) +# +# The hex encoded fingerprint of the primary key. +olcAttributeTypes: {15}( + 1.3.6.1.4.1.11591.2.4.1.1 + NAME 'gpgFingerprint' + DESC 'Fingerprint of the primary key' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) +# A list of hex encoded fingerprints of the subkeys. +olcAttributeTypes: {16}( + 1.3.6.1.4.1.11591.2.4.1.2 + NAME 'gpgSubFingerprint' + DESC 'Fingerprints of the secondary keys' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +# A list of utf8 encoded addr-spec used instead of mail/rfc822Mailbox +olcAttributeTypes: {17}( + 1.3.6.1.4.1.11591.2.4.1.3 + NAME 'gpgMailbox' + DESC 'The utf8 encoded addr-spec of a mailbox' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +# A list of hex encoded long keyids of all subkeys. +olcAttributeTypes: {18}( + 1.3.6.1.4.1.11591.2.4.1.4 + NAME 'gpgSubCertID' + DESC 'OpenPGP long subkey id' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +# +# +# Used by regular LDAP servers to indicate pgp support. +# +olcObjectClasses: {0}( + 1.3.6.1.4.1.3401.8.2.23 + NAME 'pgpServerInfo' + DESC 'An OpenPGP public keyblock store' + SUP top + STRUCTURAL MUST ( cn $ pgpBaseKeySpaceDN ) + MAY ( pgpSoftware $ pgpVersion ) ) +# +# The original PGP key object extended with a few extra attributes. +# All new software should set them but this is not enforced for +# backward compatibility +olcObjectClasses: {1}( + 1.3.6.1.4.1.3401.8.2.24 + NAME 'pgpKeyInfo' + DESC 'An OpenPGP public keyblock' + SUP top + AUXILIARY MUST ( pgpCertID $ pgpKey ) + MAY ( pgpDisabled $ pgpKeyID $ pgpKeyType $ + pgpUserID $ pgpKeyCreateTime $ pgpSignerID $ + pgpRevoked $ pgpSubKeyID $ pgpKeySize $ + pgpKeyExpireTime $ gpgFingerprint $ + gpgSubFingerprint $ gpgSubCertID $ + gpgMailbox ) ) +# +# end-of-file +# diff --git a/ldif/init.ldif b/ldif/init.ldif new file mode 100644 index 000000000..8223fb3e6 --- /dev/null +++ b/ldif/init.ldif @@ -0,0 +1,39 @@ +dn: dc=example,dc=org +objectClass: dcObject +objectClass: organization +dc: example +o: example + +dn: ou=users,dc=example,dc=org +objectClass: organizationalUnit +ou: users + +dn: cn=user,ou=users,dc=example,dc=org +cn: user +givenName: User +sn: Dummy +objectClass: inetOrgPerson +objectClass: ldapPublicKey +objectClass: pgpKeyInfo +objectClass: sambaSamAccount +objectClass: shadowAccount +objectClass: simpleSecurityObject +userPassword: {SSHA}NnpaV9UhNlh0Gd8a2z5h82KL01rpXctZ +uid: user +sambaSID: 0 +sambaNTPassword: 57d583aa46d571502aad4bb7aea09c70 +sshPublicKey: +pgpCertID: - +pgpKey: + +dn: ou=groups,dc=example,dc=org +ou: groups +objectClass: organizationalUnit +objectClass: top + +dn: cn=admin,ou=groups,dc=example,dc=org +objectClass: groupOfUniqueNames +objectClass: top +cn: admin +uniqueMember: cn=admin,dc=example,dc=org +uniqueMember: cn=user,ou=users,dc=example,dc=org diff --git a/ldif/openssh-lpk_openldap.ldif b/ldif/openssh-lpk_openldap.ldif new file mode 100644 index 000000000..e28919f26 --- /dev/null +++ b/ldif/openssh-lpk_openldap.ldif @@ -0,0 +1,9 @@ +dn: cn=openssh-lpk_openldap,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: openssh-lpk_openldap +olcAttributeTypes: {0}( 1.3.6.1.4.1.24552.500.1.1.1.13 NAME 'sshPublicKey' DES + C 'MANDATORY: OpenSSH Public key' EQUALITY octetStringMatch SYNTAX 1.3.6.1.4. + 1.1466.115.121.1.40 ) +olcObjectClasses: {0}( 1.3.6.1.4.1.24552.500.1.1.2.0 NAME 'ldapPublicKey' DESC + 'MANDATORY: OpenSSH LPK objectclass' SUP top AUXILIARY MUST ( sshPublicKey $ + uid ) ) diff --git a/ldif/orion.ldif b/ldif/orion.ldif new file mode 100644 index 000000000..c4549261c --- /dev/null +++ b/ldif/orion.ldif @@ -0,0 +1,13 @@ +dn: cn=orion-device,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: orion-device +olcAttributeTypes: {0}( 1.3.6.1.4.1.24552.500.1.1.1.990 NAME 'deviceName' + DESC 'Orion device name' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +olcAttributeTypes: {1}( 1.3.6.1.4.1.24552.500.1.1.1.991 NAME 'deviceId' + DESC 'Orion device identifier' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +olcAttributeTypes: {2}( 1.3.6.1.4.1.24552.500.1.1.1.992 NAME 'wireGuardPublicKey' + DESC 'Wireguard public key' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: {3}( 1.3.6.1.4.1.24552.500.1.1.1.993 NAME 'wireGuardIp' + DESC 'Wireguard allowed Ip' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcObjectClasses: {0}( 1.3.6.1.4.1.24552.500.1.1.2.990 NAME 'orionDevice' + DESC 'Services wireguard' SUP top STRUCTURAL MUST (deviceName $ deviceId $ wireGuardpublickey $ wireGuardIp) ) diff --git a/ldif/samba.ldif b/ldif/samba.ldif new file mode 100644 index 000000000..5106e5f8c --- /dev/null +++ b/ldif/samba.ldif @@ -0,0 +1,224 @@ +dn: cn=samba,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: samba +olcAttributeTypes: {0}( 1.3.6.1.4.1.7165.2.1.24 NAME 'sambaLMPassword' DESC 'L + anManager Password' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.1 + 21.1.26{32} SINGLE-VALUE ) +olcAttributeTypes: {1}( 1.3.6.1.4.1.7165.2.1.25 NAME 'sambaNTPassword' DESC 'M + D4 hash of the unicode password' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4 + .1.1466.115.121.1.26{32} SINGLE-VALUE ) +olcAttributeTypes: {2}( 1.3.6.1.4.1.7165.2.1.26 NAME 'sambaAcctFlags' DESC 'Ac + count Flags' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + {16} SINGLE-VALUE ) +olcAttributeTypes: {3}( 1.3.6.1.4.1.7165.2.1.27 NAME 'sambaPwdLastSet' DESC 'T + imestamp of the last password update' EQUALITY integerMatch SYNTAX 1.3.6.1.4. + 1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {4}( 1.3.6.1.4.1.7165.2.1.28 NAME 'sambaPwdCanChange' DESC + 'Timestamp of when the user is allowed to update the password' EQUALITY integ + erMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {5}( 1.3.6.1.4.1.7165.2.1.29 NAME 'sambaPwdMustChange' DESC + 'Timestamp of when the password will expire' EQUALITY integerMatch SYNTAX 1. + 3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {6}( 1.3.6.1.4.1.7165.2.1.30 NAME 'sambaLogonTime' DESC 'Ti + mestamp of last logon' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121. + 1.27 SINGLE-VALUE ) +olcAttributeTypes: {7}( 1.3.6.1.4.1.7165.2.1.31 NAME 'sambaLogoffTime' DESC 'T + imestamp of last logoff' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.12 + 1.1.27 SINGLE-VALUE ) +olcAttributeTypes: {8}( 1.3.6.1.4.1.7165.2.1.32 NAME 'sambaKickoffTime' DESC ' + Timestamp of when the user will be logged off automatically' EQUALITY integer + Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {9}( 1.3.6.1.4.1.7165.2.1.48 NAME 'sambaBadPasswordCount' D + ESC 'Bad password attempt count' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.146 + 6.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {10}( 1.3.6.1.4.1.7165.2.1.49 NAME 'sambaBadPasswordTime' D + ESC 'Time of the last bad password attempt' EQUALITY integerMatch SYNTAX 1.3. + 6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {11}( 1.3.6.1.4.1.7165.2.1.55 NAME 'sambaLogonHours' DESC ' + Logon Hours' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + {42} SINGLE-VALUE ) +olcAttributeTypes: {12}( 1.3.6.1.4.1.7165.2.1.33 NAME 'sambaHomeDrive' DESC 'D + river letter of home directory mapping' EQUALITY caseIgnoreIA5Match SYNTAX 1. + 3.6.1.4.1.1466.115.121.1.26{4} SINGLE-VALUE ) +olcAttributeTypes: {13}( 1.3.6.1.4.1.7165.2.1.34 NAME 'sambaLogonScript' DESC + 'Logon script path' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121. + 1.15{255} SINGLE-VALUE ) +olcAttributeTypes: {14}( 1.3.6.1.4.1.7165.2.1.35 NAME 'sambaProfilePath' DESC + 'Roaming profile path' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.1 + 21.1.15{255} SINGLE-VALUE ) +olcAttributeTypes: {15}( 1.3.6.1.4.1.7165.2.1.36 NAME 'sambaUserWorkstations' + DESC 'List of user workstations the user is allowed to logon to' EQUALITY cas + eIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{255} SINGLE-VALUE ) +olcAttributeTypes: {16}( 1.3.6.1.4.1.7165.2.1.37 NAME 'sambaHomePath' DESC 'Ho + me directory UNC path' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.1 + 21.1.15{128} ) +olcAttributeTypes: {17}( 1.3.6.1.4.1.7165.2.1.38 NAME 'sambaDomainName' DESC ' + Windows NT domain to which the user belongs' EQUALITY caseIgnoreMatch SYNTAX + 1.3.6.1.4.1.1466.115.121.1.15{128} ) +olcAttributeTypes: {18}( 1.3.6.1.4.1.7165.2.1.47 NAME 'sambaMungedDial' DESC ' + Base64 encoded user parameter string' EQUALITY caseExactMatch SYNTAX 1.3.6.1. + 4.1.1466.115.121.1.15{1050} ) +olcAttributeTypes: {19}( 1.3.6.1.4.1.7165.2.1.54 NAME 'sambaPasswordHistory' D + ESC 'Concatenated MD5 hashes of the salted NT passwords used on this account' + EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{32} ) +olcAttributeTypes: {20}( 1.3.6.1.4.1.7165.2.1.20 NAME 'sambaSID' DESC 'Securit + y ID' EQUALITY caseIgnoreIA5Match SUBSTR caseExactIA5SubstringsMatch SYNTAX 1 + .3.6.1.4.1.1466.115.121.1.26{64} SINGLE-VALUE ) +olcAttributeTypes: {21}( 1.3.6.1.4.1.7165.2.1.23 NAME 'sambaPrimaryGroupSID' D + ESC 'Primary Group Security ID' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4. + 1.1466.115.121.1.26{64} SINGLE-VALUE ) +olcAttributeTypes: {22}( 1.3.6.1.4.1.7165.2.1.51 NAME 'sambaSIDList' DESC 'Sec + urity ID List' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121.1. + 26{64} ) +olcAttributeTypes: {23}( 1.3.6.1.4.1.7165.2.1.19 NAME 'sambaGroupType' DESC 'N + T Group Type' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SING + LE-VALUE ) +olcAttributeTypes: {24}( 1.3.6.1.4.1.7165.2.1.21 NAME 'sambaNextUserRid' DESC + 'Next NT rid to give our for users' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1. + 1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {25}( 1.3.6.1.4.1.7165.2.1.22 NAME 'sambaNextGroupRid' DESC + 'Next NT rid to give out for groups' EQUALITY integerMatch SYNTAX 1.3.6.1.4. + 1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {26}( 1.3.6.1.4.1.7165.2.1.39 NAME 'sambaNextRid' DESC 'Nex + t NT rid to give out for anything' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1 + 466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {27}( 1.3.6.1.4.1.7165.2.1.40 NAME 'sambaAlgorithmicRidBase + ' DESC 'Base at which the samba RID generation algorithm should operate' EQUA + LITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {28}( 1.3.6.1.4.1.7165.2.1.41 NAME 'sambaShareName' DESC 'S + hare Name' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SING + LE-VALUE ) +olcAttributeTypes: {29}( 1.3.6.1.4.1.7165.2.1.42 NAME 'sambaOptionName' DESC ' + Option Name' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX + 1.3.6.1.4.1.1466.115.121.1.15{256} ) +olcAttributeTypes: {30}( 1.3.6.1.4.1.7165.2.1.43 NAME 'sambaBoolOption' DESC ' + A boolean option' EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 S + INGLE-VALUE ) +olcAttributeTypes: {31}( 1.3.6.1.4.1.7165.2.1.44 NAME 'sambaIntegerOption' DES + C 'An integer option' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1 + .27 SINGLE-VALUE ) +olcAttributeTypes: {32}( 1.3.6.1.4.1.7165.2.1.45 NAME 'sambaStringOption' DESC + 'A string option' EQUALITY caseExactIA5Match SYNTAX 1.3.6.1.4.1.1466.115.121 + .1.26 SINGLE-VALUE ) +olcAttributeTypes: {33}( 1.3.6.1.4.1.7165.2.1.46 NAME 'sambaStringListOption' + DESC 'A string list option' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466. + 115.121.1.15 ) +olcAttributeTypes: {34}( 1.3.6.1.4.1.7165.2.1.53 NAME 'sambaTrustFlags' DESC ' + Trust Password Flags' EQUALITY caseIgnoreIA5Match SYNTAX 1.3.6.1.4.1.1466.115 + .121.1.26 ) +olcAttributeTypes: {35}( 1.3.6.1.4.1.7165.2.1.58 NAME 'sambaMinPwdLength' DESC + 'Minimal password length (default: 5)' EQUALITY integerMatch SYNTAX 1.3.6.1. + 4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {36}( 1.3.6.1.4.1.7165.2.1.59 NAME 'sambaPwdHistoryLength' + DESC 'Length of Password History Entries (default: 0 => off)' EQUALITY intege + rMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {37}( 1.3.6.1.4.1.7165.2.1.60 NAME 'sambaLogonToChgPwd' DES + C 'Force Users to logon for password change (default: 0 => off, 2 => on)' EQU + ALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {38}( 1.3.6.1.4.1.7165.2.1.61 NAME 'sambaMaxPwdAge' DESC 'M + aximum password age, in seconds (default: -1 => never expire passwords)' EQUA + LITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {39}( 1.3.6.1.4.1.7165.2.1.62 NAME 'sambaMinPwdAge' DESC 'M + inimum password age, in seconds (default: 0 => allow immediate password chang + e)' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {40}( 1.3.6.1.4.1.7165.2.1.63 NAME 'sambaLockoutDuration' D + ESC 'Lockout duration in minutes (default: 30, -1 => forever)' EQUALITY integ + erMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {41}( 1.3.6.1.4.1.7165.2.1.64 NAME 'sambaLockoutObservation + Window' DESC 'Reset time after lockout in minutes (default: 30)' EQUALITY int + egerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {42}( 1.3.6.1.4.1.7165.2.1.65 NAME 'sambaLockoutThreshold' + DESC 'Lockout users after bad logon attempts (default: 0 => off)' EQUALITY in + tegerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {43}( 1.3.6.1.4.1.7165.2.1.66 NAME 'sambaForceLogoff' DESC + 'Disconnect Users outside logon hours (default: -1 => off, 0 => on)' EQUALITY + integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {44}( 1.3.6.1.4.1.7165.2.1.67 NAME 'sambaRefuseMachinePwdCh + ange' DESC 'Allow Machine Password changes (default: 0 => off)' EQUALITY inte + gerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {45}( 1.3.6.1.4.1.7165.2.1.68 NAME 'sambaClearTextPassword' + DESC 'Clear text password (used for trusted domain passwords)' EQUALITY octe + tStringMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 ) +olcAttributeTypes: {46}( 1.3.6.1.4.1.7165.2.1.69 NAME 'sambaPreviousClearTextP + assword' DESC 'Previous clear text password (used for trusted domain password + s)' EQUALITY octetStringMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 ) +olcAttributeTypes: {47}( 1.3.6.1.4.1.7165.2.1.70 NAME 'sambaTrustType' DESC 'T + ype of trust' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SING + LE-VALUE ) +olcAttributeTypes: {48}( 1.3.6.1.4.1.7165.2.1.71 NAME 'sambaTrustAttributes' D + ESC 'Trust attributes for a trusted domain' EQUALITY integerMatch SYNTAX 1.3. + 6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {49}( 1.3.6.1.4.1.7165.2.1.72 NAME 'sambaTrustDirection' DE + SC 'Direction of a trust' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466.115.1 + 21.1.27 SINGLE-VALUE ) +olcAttributeTypes: {50}( 1.3.6.1.4.1.7165.2.1.73 NAME 'sambaTrustPartner' DESC + 'Fully qualified name of the domain with which a trust exists' EQUALITY case + IgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} ) +olcAttributeTypes: {51}( 1.3.6.1.4.1.7165.2.1.74 NAME 'sambaFlatName' DESC 'Ne + tBIOS name of a domain' EQUALITY caseIgnoreMatch SYNTAX 1.3.6.1.4.1.1466.115. + 121.1.15{128} ) +olcAttributeTypes: {52}( 1.3.6.1.4.1.7165.2.1.75 NAME 'sambaTrustAuthOutgoing' + DESC 'Authentication information for the outgoing portion of a trust' EQUALIT + Y caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1050} ) +olcAttributeTypes: {53}( 1.3.6.1.4.1.7165.2.1.76 NAME 'sambaTrustAuthIncoming' + DESC 'Authentication information for the incoming portion of a trust' EQUALIT + Y caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1050} ) +olcAttributeTypes: {54}( 1.3.6.1.4.1.7165.2.1.77 NAME 'sambaSecurityIdentifier + ' DESC 'SID of a trusted domain' EQUALITY caseIgnoreIA5Match SUBSTR caseExact + IA5SubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{64} SINGLE-VALUE ) +olcAttributeTypes: {55}( 1.3.6.1.4.1.7165.2.1.78 NAME 'sambaTrustForestTrustIn + fo' DESC 'Forest trust information for a trusted domain object' EQUALITY case + ExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1050} ) +olcAttributeTypes: {56}( 1.3.6.1.4.1.7165.2.1.79 NAME 'sambaTrustPosixOffset' + DESC 'POSIX offset of a trust' EQUALITY integerMatch SYNTAX 1.3.6.1.4.1.1466. + 115.121.1.27 SINGLE-VALUE ) +olcAttributeTypes: {57}( 1.3.6.1.4.1.7165.2.1.80 NAME 'sambaSupportedEncryptio + nTypes' DESC 'Supported encryption types of a trust' EQUALITY integerMatch SY + NTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE ) +olcObjectClasses: {0}( 1.3.6.1.4.1.7165.2.2.6 NAME 'sambaSamAccount' DESC 'Sam + ba 3.0 Auxilary SAM Account' SUP top AUXILIARY MUST ( uid $ sambaSID ) MAY ( + cn $ sambaLMPassword $ sambaNTPassword $ sambaPwdLastSet $ sambaLogonTime $ s + ambaLogoffTime $ sambaKickoffTime $ sambaPwdCanChange $ sambaPwdMustChange $ + sambaAcctFlags $ displayName $ sambaHomePath $ sambaHomeDrive $ sambaLogonScr + ipt $ sambaProfilePath $ description $ sambaUserWorkstations $ sambaPrimaryGr + oupSID $ sambaDomainName $ sambaMungedDial $ sambaBadPasswordCount $ sambaBad + PasswordTime $ sambaPasswordHistory $ sambaLogonHours ) ) +olcObjectClasses: {1}( 1.3.6.1.4.1.7165.2.2.4 NAME 'sambaGroupMapping' DESC 'S + amba Group Mapping' SUP top AUXILIARY MUST ( gidNumber $ sambaSID $ sambaGrou + pType ) MAY ( displayName $ description $ sambaSIDList ) ) +olcObjectClasses: {2}( 1.3.6.1.4.1.7165.2.2.14 NAME 'sambaTrustPassword' DESC + 'Samba Trust Password' SUP top STRUCTURAL MUST ( sambaDomainName $ sambaNTPas + sword $ sambaTrustFlags ) MAY ( sambaSID $ sambaPwdLastSet ) ) +olcObjectClasses: {3}( 1.3.6.1.4.1.7165.2.2.15 NAME 'sambaTrustedDomainPasswor + d' DESC 'Samba Trusted Domain Password' SUP top STRUCTURAL MUST ( sambaDomain + Name $ sambaSID $ sambaClearTextPassword $ sambaPwdLastSet ) MAY sambaPreviou + sClearTextPassword ) +olcObjectClasses: {4}( 1.3.6.1.4.1.7165.2.2.5 NAME 'sambaDomain' DESC 'Samba D + omain Information' SUP top STRUCTURAL MUST ( sambaDomainName $ sambaSID ) MAY + ( sambaNextRid $ sambaNextGroupRid $ sambaNextUserRid $ sambaAlgorithmicRidB + ase $ sambaMinPwdLength $ sambaPwdHistoryLength $ sambaLogonToChgPwd $ sambaM + axPwdAge $ sambaMinPwdAge $ sambaLockoutDuration $ sambaLockoutObservationWin + dow $ sambaLockoutThreshold $ sambaForceLogoff $ sambaRefuseMachinePwdChange + ) ) +olcObjectClasses: {5}( 1.3.6.1.4.1.7165.2.2.7 NAME 'sambaUnixIdPool' DESC 'Poo + l for allocating UNIX uids/gids' SUP top AUXILIARY MUST ( uidNumber $ gidNumb + er ) ) +olcObjectClasses: {6}( 1.3.6.1.4.1.7165.2.2.8 NAME 'sambaIdmapEntry' DESC 'Map + ping from a SID to an ID' SUP top AUXILIARY MUST sambaSID MAY ( uidNumber $ g + idNumber ) ) +olcObjectClasses: {7}( 1.3.6.1.4.1.7165.2.2.9 NAME 'sambaSidEntry' DESC 'Struc + tural Class for a SID' SUP top STRUCTURAL MUST sambaSID ) +olcObjectClasses: {8}( 1.3.6.1.4.1.7165.2.2.10 NAME 'sambaConfig' DESC 'Samba + Configuration Section' SUP top AUXILIARY MAY description ) +olcObjectClasses: {9}( 1.3.6.1.4.1.7165.2.2.11 NAME 'sambaShare' DESC 'Samba S + hare Section' SUP top STRUCTURAL MUST sambaShareName MAY description ) +olcObjectClasses: {10}( 1.3.6.1.4.1.7165.2.2.12 NAME 'sambaConfigOption' DESC + 'Samba Configuration Option' SUP top STRUCTURAL MUST sambaOptionName MAY ( sa + mbaBoolOption $ sambaIntegerOption $ sambaStringOption $ sambaStringListoptio + n $ description ) ) +olcObjectClasses: {11}( 1.3.6.1.4.1.7165.2.2.16 NAME 'sambaTrustedDomain' DESC + 'Samba Trusted Domain Object' SUP top STRUCTURAL MUST cn MAY ( sambaTrustTyp + e $ sambaTrustAttributes $ sambaTrustDirection $ sambaTrustPartner $ sambaFla + tName $ sambaTrustAuthOutgoing $ sambaTrustAuthIncoming $ sambaSecurityIdenti + fier $ sambaTrustForestTrustInfo $ sambaTrustPosixOffset $ sambaSupportedEncr + yptionTypes) ) diff --git a/migrations/20220922092725_initial.down.sql b/migrations/20220922092725_initial.down.sql new file mode 100644 index 000000000..e4dd7e1c8 --- /dev/null +++ b/migrations/20220922092725_initial.down.sql @@ -0,0 +1,19 @@ +DROP VIEW wireguard_peer_stats_view; +DROP INDEX peer_stats_device_id_collected_at; +DROP TABLE webauthn; +DROP TABLE session; +DROP TABLE wireguard_peer_stats; +DROP TABLE wireguard_network; +DROP TABLE webhook; +DROP TABLE "wallet"; +DROP TABLE settings; +DROP TABLE openidclientauthcode; +DROP TABLE openidclient; +DROP TABLE oauth2token; +DROP TABLE oauth2client; +DROP TABLE "group_user"; +DROP TABLE "group"; +DROP TABLE "device"; +DROP TABLE "user"; +DROP TABLE authorizedapps; +DROP TABLE authorization_code; diff --git a/migrations/20220922092725_initial.up.sql b/migrations/20220922092725_initial.up.sql new file mode 100644 index 000000000..f17eab6a4 --- /dev/null +++ b/migrations/20220922092725_initial.up.sql @@ -0,0 +1,196 @@ +CREATE TABLE authorization_code ( + id bigserial PRIMARY KEY, + "user" text NOT NULL, + client_id text NOT NULL, + code text NOT NULL UNIQUE, + redirect_uri text NOT NULL, + scope text NOT NULL, + auth_time bigint NOT NULL +); +CREATE TABLE authorizedapps ( + id bigserial PRIMARY KEY, + username text NOT NULL, + client_id text NOT NULL, + home_url text NOT NULL, + date text NOT NULL +); +CREATE TABLE "user" ( + id bigserial PRIMARY KEY, + username text UNIQUE NOT NULL, + password_hash text NOT NULL, + last_name text NOT NULL, + first_name text NOT NULL, + email text NOT NULL, + phone text NULL, + ssh_key text NULL, + pgp_key text NULL, + pgp_cert_id text NULL, + totp_enabled boolean NOT NULL DEFAULT false, + totp_secret bytea NULL +); +CREATE TABLE "device" ( + id bigserial PRIMARY KEY, + name text NOT NULL, + wireguard_ip text NOT NULL, + wireguard_pubkey text NOT NULL, + user_id bigint NOT NULL, + created timestamp without time zone NOT NULL, + FOREIGN KEY(user_id) REFERENCES "user"(id), + CONSTRAINT name_user UNIQUE (name, user_id) +); +CREATE TABLE "group" (id bigserial PRIMARY KEY, name text UNIQUE NOT NULL); +CREATE TABLE "group_user" ( + group_id bigint REFERENCES "group"(id) ON DELETE CASCADE, + user_id bigint REFERENCES "user"(id) ON DELETE CASCADE, + CONSTRAINT group_user_unique UNIQUE (group_id, user_id) +); +CREATE TABLE oauth2client ( + id bigserial PRIMARY KEY, + "user" text NOT NULL, + client_id text NOT NULL UNIQUE, + client_secret text NOT NULL, + redirect_uri text NOT NULL, + scope text NOT NULL +); +CREATE TABLE oauth2token ( + id bigserial PRIMARY KEY, + access_token text NOT NULL UNIQUE, + refresh_token text NOT NULL UNIQUE, + redirect_uri text NOT NULL, + scope text NOT NULL, + expires_in bigint NOT NULL +); +CREATE TABLE openidclient ( + id bigserial PRIMARY KEY, + name text NOT NULL, + description text NOT NULL, + home_url text NOT NULL UNIQUE, + client_id text NOT NULL UNIQUE, + client_secret text NOT NULL UNIQUE, + redirect_uri text NOT NULL, + enabled boolean NOT NULL DEFAULT true +); +CREATE TABLE openidclientauthcode ( + id bigserial PRIMARY KEY, + "user" text NOT NULL, + code text NOT NULL UNIQUE, + client_id text NOT NULL UNIQUE, + state text NOT NULL UNIQUE, + scope text NOT NULL, + redirect_uri text NOT NULL, + nonce text +); +CREATE TABLE settings ( + id bigserial PRIMARY KEY, + web3_enabled boolean NOT NULL, + openid_enabled boolean NOT NULL, + oauth_enabled boolean NOT NULL, + ldap_enabled boolean NOT NULL, + wireguard_enabled boolean NOT NULL, + webhooks_enabled boolean NOT NULL, + worker_enabled boolean NOT NULL, + challenge_template text NOT NULL +); +CREATE TABLE "wallet" ( + id bigserial PRIMARY KEY, + user_id bigint NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + address text NOT NULL UNIQUE, + challenge_message text NOT NULL, + challenge_signature text NULL, + creation_timestamp timestamp without time zone NOT NULL, + validation_timestamp timestamp without time zone NULL, + name text NOT NULL DEFAULT '', + chain_id bigint NOT NULL DEFAULT 0 +); +CREATE TABLE webhook ( + id bigserial PRIMARY KEY, + url text NOT NULL UNIQUE, + description text NOT NULL, + token text NOT NULL, + enabled boolean NOT NULL, + on_user_created boolean NOT NULL DEFAULT false, + on_user_deleted boolean NOT NULL DEFAULT false, + on_user_modified boolean NOT NULL DEFAULT false, + on_hwkey_provision boolean NOT NULL DEFAULT false +); +CREATE TABLE wireguard_network ( + id bigserial PRIMARY KEY, + name text NOT NULL, + address text NOT NULL, + port integer NOT NULL, + pubkey text NOT NULL, + prvkey text NOT NULL, + endpoint text NOT NULL, + dns text, + allowed_ips text, + connected_at timestamp without time zone +); +CREATE TABLE wireguard_peer_stats ( + id bigserial PRIMARY KEY, + device_id bigint NOT NULL, + collected_at timestamp without time zone NOT NULL DEFAULT current_timestamp, + network bigint NOT NULL, + endpoint text, + upload bigint NOT NULL, + download bigint NOT NULL, + latest_handshake timestamp without time zone NOT NULL, + allowed_ips text, + FOREIGN KEY (device_id) REFERENCES device(id) ON DELETE CASCADE +); +CREATE TABLE session ( + id text PRIMARY KEY NOT NULL, + user_id bigint NOT NULL, + state smallint NOT NULL, + created timestamp without time zone NOT NULL, + expires timestamp without time zone NOT NULL, + webauthn_challenge bytea NULL, + FOREIGN KEY(user_id) REFERENCES "user"(id) +); +CREATE TABLE webauthn ( + id bigserial PRIMARY KEY, + user_id bigint NOT NULL, + passkey bytea NOT NULL, + FOREIGN KEY(user_id) REFERENCES "user"(id) +); +CREATE INDEX peer_stats_device_id_collected_at on wireguard_peer_stats (device_id, collected_at); +CREATE VIEW wireguard_peer_stats_view AS + SELECT + device_id, + greatest(upload - lag(upload) OVER (PARTITION BY device_id ORDER BY collected_at), 0) AS upload, + greatest(download - lag(download) OVER (PARTITION BY device_id ORDER BY collected_at), 0) AS download, + (latest_handshake - (lag(latest_handshake) OVER (PARTITION BY device_id ORDER BY collected_at))) AS latest_handshake_diff, + latest_handshake, + collected_at, + network, + endpoint, + allowed_ips + FROM wireguard_peer_stats; +INSERT INTO + "user" ( + username, + password_hash, + last_name, + first_name, + email + ) +VALUES + ( + 'admin', + '$argon2id$v=19$m=4096,t=3,p=1$XHgZw2qF/ryF1gOtuLLyZg$AF/Z0EIWk4KGetBSANEnkdK+IcoIWQLjYGuBQjas8SY', + -- pass123 + 'Administrator', + 'DefGuard', + 'admin@defguard' + ); +INSERT INTO + "group" (name) +VALUES + ('admin'); +INSERT INTO + "group_user" (group_id, user_id) +VALUES + (1, 1); +INSERT INTO settings VALUES ( + 1, true, true, true, true, true, true, true, + 'By signing this message you confirm that you''re the owner of the wallet' +); diff --git a/migrations/20221006125653_web3_mfa.down.sql b/migrations/20221006125653_web3_mfa.down.sql new file mode 100644 index 000000000..823f860a3 --- /dev/null +++ b/migrations/20221006125653_web3_mfa.down.sql @@ -0,0 +1 @@ +ALTER TABLE "session" DROP COLUMN web3_challenge; diff --git a/migrations/20221006125653_web3_mfa.up.sql b/migrations/20221006125653_web3_mfa.up.sql new file mode 100644 index 000000000..321f845a5 --- /dev/null +++ b/migrations/20221006125653_web3_mfa.up.sql @@ -0,0 +1 @@ +ALTER TABLE "session" ADD COLUMN web3_challenge text NULL; diff --git a/migrations/20221010085106_extend_mfa.down.sql b/migrations/20221010085106_extend_mfa.down.sql new file mode 100644 index 000000000..693944f00 --- /dev/null +++ b/migrations/20221010085106_extend_mfa.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE webauthn DROP COLUMN name; +ALTER TABLE wallet DROP COLUMN use_for_mfa; +ALTER TABLE "user" DROP COLUMN mfa_method; + +DROP TYPE mfa_method; diff --git a/migrations/20221010085106_extend_mfa.up.sql b/migrations/20221010085106_extend_mfa.up.sql new file mode 100644 index 000000000..211d8230d --- /dev/null +++ b/migrations/20221010085106_extend_mfa.up.sql @@ -0,0 +1,10 @@ +CREATE TYPE mfa_method AS ENUM ( + 'none', + 'one_time_password', + 'webauthn', + 'web3' +); + +ALTER TABLE "user" ADD COLUMN mfa_method mfa_method NOT NULL DEFAULT 'none'; +ALTER TABLE wallet ADD COLUMN use_for_mfa boolean NOT NULL DEFAULT true; +ALTER TABLE webauthn ADD COLUMN name text NOT NULL; diff --git a/migrations/20221012084225_inet.down.sql b/migrations/20221012084225_inet.down.sql new file mode 100644 index 000000000..bd2decb04 --- /dev/null +++ b/migrations/20221012084225_inet.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE authorizedapps DROP name text; +ALTER TABLE "user" DROP mfa_enabled; +ALTER TABLE "user" DROP recovery_codes; +ALTER TABLE wireguard_network ALTER allowed_ips DROP NOT NULL; +ALTER TABLE wireguard_network ALTER allowed_ips TYPE text USING array_to_string(allowed_ips, ','); +ALTER TABLE wireguard_network ALTER address TYPE text USING address::text; diff --git a/migrations/20221012084225_inet.up.sql b/migrations/20221012084225_inet.up.sql new file mode 100644 index 000000000..1d7af24ab --- /dev/null +++ b/migrations/20221012084225_inet.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE wireguard_network ALTER address TYPE inet USING address::inet; +ALTER TABLE wireguard_network ALTER allowed_ips TYPE inet[] USING string_to_array(replace(allowed_ips, ' ', ''), ',')::inet[]; +ALTER TABLE wireguard_network ALTER allowed_ips SET NOT NULL; +ALTER TABLE "user" ADD recovery_codes text[] NOT NULL DEFAULT array[]::text[]; +ALTER TABLE "user" ADD mfa_enabled boolean NOT NULL DEFAULT false; +ALTER TABLE authorizedapps ADD name text NOT NULL DEFAULT 'app'; diff --git a/migrations/20221017101958_openid.down.sql b/migrations/20221017101958_openid.down.sql new file mode 100644 index 000000000..a9600e448 --- /dev/null +++ b/migrations/20221017101958_openid.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE authorizedapps DROP CONSTRAINT authorizedapps_user_id_fkey; +ALTER TABLE authorizedapps ADD username text NULL; +UPDATE authorizedapps SET username = "user".username FROM "user" WHERE "user".id = authorizedapps.user_id; +ALTER TABLE authorizedapps ALTER username SET NOT NULL; +ALTER TABLE authorizedapps DROP user_id; diff --git a/migrations/20221017101958_openid.up.sql b/migrations/20221017101958_openid.up.sql new file mode 100644 index 000000000..bd82eb22c --- /dev/null +++ b/migrations/20221017101958_openid.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE authorizedapps ADD user_id bigint NULL; +UPDATE authorizedapps SET user_id = "user".id FROM "user" WHERE "user".username = authorizedapps.username; +ALTER TABLE authorizedapps DROP username; +ALTER TABLE authorizedapps ALTER user_id SET NOT NULL; +ALTER TABLE authorizedapps ADD FOREIGN KEY(user_id) REFERENCES "user"(id); diff --git a/model-derive/Cargo.toml b/model-derive/Cargo.toml new file mode 100644 index 000000000..36eca33e4 --- /dev/null +++ b/model-derive/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "model_derive" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +quote = "1.0" +syn = "1.0" diff --git a/model-derive/src/lib.rs b/model-derive/src/lib.rs new file mode 100644 index 000000000..8271a37dd --- /dev/null +++ b/model-derive/src/lib.rs @@ -0,0 +1,186 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + parse_macro_input, Data, DataStruct, DeriveInput, Field, Fields, FieldsNamed, Ident, Path, + Type, TypePath, +}; + +struct Attrs(Ident); + +impl Parse for Attrs { + fn parse(input: ParseStream) -> syn::Result { + let content; + syn::parenthesized!(content in input); + Ok(Self(content.parse()?)) + } +} + +fn model_attr(f: &Field) -> Option { + for attr in &f.attrs { + if let Some(path_seg) = attr.path.segments.first() { + if path_seg.ident == "model" { + // FIXME: this is a short-cut that returns tokens with parentheses, e.g. "(enum)". + return Some(attr.tokens.to_string()); + } + } + } + None +} + +fn field_type(ty: &Type) -> Option<&Ident> { + if let Type::Path(TypePath { + path: Path { segments, .. }, + .. + }) = ty + { + if let Some(segment) = segments.first() { + return Some(&segment.ident); + } + } + None +} + +#[proc_macro_derive(Model, attributes(table, model))] +pub fn derive(input: TokenStream) -> TokenStream { + let ast = parse_macro_input!(input as DeriveInput); + let name = &ast.ident; + // Find the "table" attribute + let attribute = ast + .attrs + .iter() + .find(|a| a.path.segments.len() == 1 && a.path.segments[0].ident == "table"); + // Parse table name if attribute is present + let table_name = match attribute { + Some(attribute) => { + let attributes: Attrs = + syn::parse2(attribute.tokens.clone()).expect("Invalid table attribute"); + attributes.0.to_string() + } + _ => name.to_string().to_ascii_lowercase(), + }; + + let fields = if let Data::Struct(DataStruct { + fields: Fields::Named(FieldsNamed { named, .. }), + .. + }) = ast.data + { + named + } else { + // fail for other but `struct` + unimplemented!(); + }; + + // comma-separated fields ("field1", "field2", ...) + let mut cs_fields = String::new(); + // comma-separated fields with quotes and aliases ("field1", "field2" "field2: _", ...) + let mut cs_aliased_fields = String::new(); + // comma-separated values ($1, $2, ...) + let mut cs_values = String::new(); + // comma-separated setters ("field1" = $2, "field2" = $3, ...) + let mut cs_setters = String::new(); + + let mut add_comma = false; + let mut value_number = 1; + fields.iter().for_each(|field| { + if let Some(name) = &field.ident { + if name != "id" { + if add_comma { + cs_fields.push_str(", "); + cs_aliased_fields.push_str(", "); + cs_values.push_str(", "); + cs_setters.push_str(", "); + } else { + add_comma = true; + } + + let name_string = name.to_string(); + + cs_fields.push('"'); + cs_fields.push_str(&name_string); + cs_fields.push('"'); + cs_aliased_fields.push('"'); + cs_aliased_fields.push_str(&name_string); + cs_aliased_fields.push('"'); + if model_attr(field).is_some() { + cs_aliased_fields.push_str(" \""); + cs_aliased_fields.push_str(&name_string); + cs_aliased_fields.push_str(": _\""); + } + cs_values.push('$'); + cs_values.push_str(&value_number.to_string()); + + value_number += 1; + + cs_setters.push('"'); + cs_setters.push_str(&name.to_string()); + cs_setters.push_str("\" = $"); + cs_setters.push_str(&value_number.to_string()); + } + } + }); + + // TODO: handle fields wrapped in Option + // field arguments for queries + let insert_args = fields.iter().filter_map(|field| { + if let Some(name) = &field.ident { + if name != "id" { + if let Some(tokens) = model_attr(field) { + if tokens == "(enum)" { + if let Some(field_type) = field_type(&field.ty) { + return Some(quote! { &self.#name as &#field_type }); + } + } else { + return Some(quote! { &self.#name }); + } + } + return Some(quote! { self.#name }); + } + } + None + }); + let update_args = insert_args.clone(); + + // queries + let all_query = format!("SELECT id \"id?\", {cs_aliased_fields} FROM \"{table_name}\""); + let find_by_id_query = + format!("SELECT id \"id?\", {cs_aliased_fields} FROM \"{table_name}\" WHERE id = $1"); + let delete_query = format!("DELETE FROM \"{table_name}\" WHERE id = $1"); + let insert_query = + format!("INSERT INTO \"{table_name}\" ({cs_fields}) VALUES ({cs_values}) RETURNING id"); + let update_query = format!("UPDATE \"{table_name}\" SET {cs_setters} WHERE id = $1"); + + quote! { + impl #name { + pub async fn find_by_id(pool: &DbPool, id: i64) -> Result, sqlx::Error> { + sqlx::query_as!(Self, #find_by_id_query, id).fetch_optional(pool).await + } + + // TODO: add limit and offset + pub async fn all(pool: &DbPool) -> Result, sqlx::Error> { + sqlx::query_as!(Self, #all_query).fetch_all(pool).await + } + + pub async fn delete(self, pool: &DbPool) -> Result<(), sqlx::Error> { + if let Some(id) = self.id { + sqlx::query!(#delete_query, id).execute(pool).await?; + } + Ok(()) + } + + pub async fn save(&mut self, pool: &DbPool) -> Result<(), sqlx::Error> { + match self.id { + None => { + let id = sqlx::query_scalar!(#insert_query, #(#insert_args,)*).fetch_one(pool).await?; + self.id = Some(id); + } + Some(id) => { + sqlx::query!(#update_query, id, #(#update_args,)*).execute(pool).await?; + } + } + Ok(()) + } + } + } + .into() +} diff --git a/proto/build.sh b/proto/build.sh new file mode 100755 index 000000000..80f51e4b6 --- /dev/null +++ b/proto/build.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +set -e + +log() { + echo "$@" > /dev/stderr +} + +fail() { + log "$@" + exit 1 +} + +THIS_DIR=$( cd "$(dirname "$0")" ; pwd ) +PROTOC=/usr/bin/protoc +GRPC_CPP_PLUGIN=/usr/bin/grpc_cpp_plugin + +for BIN in PROTOC GRPC_CPP_PLUGIN ; do + test -x "${!BIN}" || fail "Need ${!BIN}" +done + +TMP_DIR= + +onExit() { + [ -z "$TMP_DIR" ] || rm -r "$TMP_DIR" +} + +trap onExit EXIT + +TMP_DIR=$(mktemp -d) + +cd "$THIS_DIR" +find -type f -name \*.proto -print0 \ + | xargs -0 \ + protoc \ + --plugin=protoc-gen-grpc="$GRPC_CPP_PLUGIN" \ + --cpp_out="$TMP_DIR" \ + --grpc_out="$TMP_DIR" + +find "$TMP_DIR" diff --git a/proto/client/local_state.proto b/proto/client/local_state.proto new file mode 100644 index 000000000..a3ac380cb --- /dev/null +++ b/proto/client/local_state.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package client; + +message LocalState { + // The unique identifier of this device. + string device_id = 1; + // Wireguard private key of this device. + string wg_private_key = 2; + // JWT token used for authenticating to the central server. + string jwt_token = 3; +} diff --git a/proto/core/auth.proto b/proto/core/auth.proto new file mode 100644 index 000000000..cb069a740 --- /dev/null +++ b/proto/core/auth.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package auth; + +service AuthService { + rpc Authenticate(AuthenticateRequest) returns (AuthenticateResponse); +} + +message AuthenticateRequest { + string username = 1; + string password = 2; +} + +message AuthenticateResponse { + string token = 1; +} \ No newline at end of file diff --git a/proto/core/vpn.proto b/proto/core/vpn.proto new file mode 100644 index 000000000..6db43cd39 --- /dev/null +++ b/proto/core/vpn.proto @@ -0,0 +1,88 @@ +syntax = "proto3"; + +package vpn; + +// This service is used by clients to create a VPN mesh. +service VpnService { + // Store DeviceInfo data on the server so that it can be distributed to other + // clients and used to broker connections. Perform update if the device with + // given id already exists. + rpc RegisterDevice(RegisterDeviceRequest) returns (RegisterDeviceResponse); + + // Get static information about all devices in the system. As the information + // changes, new versions will be streamed back. + rpc GetDevicesInfo(GetDevicesInfoRequest) + returns (stream DevicesInfo); + + // Broker creating a new connection. + // Devices learn about each other via GetDevicesInfo. When they do, they + // should try to connect to each other via this call. Both should send each + // other their ICE info. The server, once it gets a response will forward it + // to the other client and close the stream. The only reason why the response + // is a stream rather than a single RPC is to reflect this RPC's potential + // long duration (e.g. when one of the devices is offline). Each client will + // have at most one `BrokerConnection` per one peer pending. + rpc BrokerConnection(BrokerConnectionRequest) + returns (stream BrokerConnectionResponse); +} + +// The information about a device. +message DeviceInfo { + // User-presentable device name. + string name = 1; + // Unique machine identifier. This is stored on the client device. + int64 id = 2; + // The IP address assigned to the device on the WG interface. + string ip_address = 3; + // Wireguard public key of the device. + string wg_public_key = 4; +} + +message GetDevicesInfoRequest { + int64 device_id = 1; +} + +// A list of DeviceInfos. +message DevicesInfo { + repeated DeviceInfo devices = 1; +} + +message BrokerConnectionRequest { + int64 my_device_id = 1; + int64 peer_device_id = 2; + ICEAgentInfo my_ice_agent_info = 3; +} + +message BrokerConnectionResponse { + int64 peer_device_id = 2; + ICEAgentInfo peer_ice_agent_info = 3; +} + +message RegisterDeviceRequest { + DeviceInfo device = 1; +} + +message RegisterDeviceResponse { + +} + +// This structure contains the information necessary to construct a remote ICE +// agent, except for username fragment +message ICEAgentInfo { + // ICE user & password as described in section 5.3 of RFC 8445. This is + // generated randomly by the clients on every restart. + string user = 1; + string password = 2; + + repeated ICECandidate candidates = 3; +} + +// This structure describes a single ICE candidate according to section 5.3 of +// RFC 8445. Refer to the RFC for meaning of the fields. +message ICECandidate { + string foundation = 1; + int32 priority = 2; + string ip_address = 3; + int32 port = 4; + int32 type = 5; +} diff --git a/proto/wireguard/gateway.proto b/proto/wireguard/gateway.proto new file mode 100644 index 000000000..25b35de5b --- /dev/null +++ b/proto/wireguard/gateway.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; +package gateway; + +import "google/protobuf/empty.proto"; + +message Configuration { + string name = 1; + string prvkey = 2; + string address = 3; + uint32 port = 4; + repeated Peer peers = 5; +} + +enum UpdateType { + CREATE = 0; + MODIFY = 1; + DELETE = 2; +} + +message Peer { + string pubkey = 1; + repeated string allowed_ips = 2; +} + +message Update { + UpdateType update_type = 1; + oneof update { + Peer peer = 2; + Configuration network = 3; + } +} + +message PeerStats { + string public_key = 1; + string endpoint = 2; + int64 upload = 3; + int64 download = 4; + int64 keepalive_interval = 5; + int64 latest_handshake = 6; + string allowed_ips = 7; +} + +service GatewayService { + rpc Config (google.protobuf.Empty) returns (Configuration); + rpc Updates (google.protobuf.Empty) returns (stream Update); + rpc Stats (stream PeerStats) returns (google.protobuf.Empty); +} + diff --git a/proto/worker/worker.proto b/proto/worker/worker.proto new file mode 100644 index 000000000..d96463e7b --- /dev/null +++ b/proto/worker/worker.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; +package worker; +import "google/protobuf/empty.proto"; + +message Worker { + string id = 1; +} + +message JobStatus { + string id = 1; + uint32 job_id = 2; + bool success = 3; + string public_key = 4; + string ssh_key = 5; + string fingerprint = 6; + string error = 7; +} + +message GetJobResponse { + string first_name = 1; + string last_name = 2; + string email = 3; + uint32 job_id = 4; +} + +service WorkerService { + rpc RegisterWorker (Worker) returns (google.protobuf.Empty) {} + rpc GetJob (Worker) returns (GetJobResponse) {} + rpc SetJobDone (JobStatus) returns (google.protobuf.Empty) {} +} diff --git a/sqlx-data.json b/sqlx-data.json new file mode 100644 index 000000000..ee4bca8f8 --- /dev/null +++ b/sqlx-data.json @@ -0,0 +1,3912 @@ +{ + "db": "PostgreSQL", + "005c971275049ba3d2cd877c04ddd506f865eb6a20e2b805c1c45a8da3de4a6c": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Bool" + ] + } + }, + "query": "UPDATE \"openidclient\" SET \"name\" = $2, \"description\" = $3, \"home_url\" = $4, \"client_id\" = $5, \"client_secret\" = $6, \"redirect_uri\" = $7, \"enabled\" = $8 WHERE id = $1" + }, + "00cc2dd1714e02c3393b989440f675ff92f86c12946f3d75d35ffdf4e0b8492f": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + } + }, + "query": "UPDATE \"user\" SET mfa_enabled = TRUE, recovery_codes = $2 WHERE id = $1" + }, + "0341a2debb445319fe7b1905aaad7f797b61940a342e08c58b6d2205d9c2eb87": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8", + "Timestamp", + "Int8", + "Text", + "Int8", + "Int8", + "Timestamp", + "Text" + ] + } + }, + "query": "INSERT INTO \"wireguard_peer_stats\" (\"device_id\", \"collected_at\", \"network\", \"endpoint\", \"upload\", \"download\", \"latest_handshake\", \"allowed_ips\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id" + }, + "048b548a3b07078af6e10bb9ce4f2795124f3b47f2ec1a77e94431ea86cc8c87": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8", + "Timestamp" + ] + } + }, + "query": "INSERT INTO \"device\" (\"name\", \"wireguard_ip\", \"wireguard_pubkey\", \"user_id\", \"created\") VALUES ($1, $2, $3, $4, $5) RETURNING id" + }, + "06a4279a416a752095d6195668b790b1b703806fab9b9ca0af645b1b9bc1c735": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Text" + ] + } + }, + "query": "INSERT INTO \"authorizedapps\" (\"user_id\", \"client_id\", \"home_url\", \"date\", \"name\") VALUES ($1, $2, $3, $4, $5) RETURNING id" + }, + "06be7b39c87c3a1d1465dfb007937b5b584f60e9094a18936b482c4dfb6b1aca": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"name\" FROM \"group\"" + }, + "09290c24060f6fd858e779b334029900f0b04785b1012d5e6bd2897ef8440585": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Int8", + "Text", + "Text", + "Timestamp", + "Timestamp", + "Bool" + ] + } + }, + "query": "INSERT INTO \"wallet\" (\"user_id\", \"address\", \"name\", \"chain_id\", \"challenge_message\", \"challenge_signature\", \"creation_timestamp\", \"validation_timestamp\", \"use_for_mfa\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id" + }, + "0b64dc92dc09d7fc080c6d2f2d016db4c11f9a99442428a9e473c75c7633927b": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"group\" WHERE id = $1" + }, + "0bdf1873f50350d16aad6ec9aed420dcd172a411e14b858a798b24a4b02bdf16": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text", + "Text", + "Int8", + "Text", + "Text", + "Timestamp", + "Timestamp", + "Bool" + ] + } + }, + "query": "UPDATE \"wallet\" SET \"user_id\" = $2, \"address\" = $3, \"name\" = $4, \"chain_id\" = $5, \"challenge_message\" = $6, \"challenge_signature\" = $7, \"creation_timestamp\" = $8, \"validation_timestamp\" = $9, \"use_for_mfa\" = $10 WHERE id = $1" + }, + "0d94c4f8df85d5d219dee2e4e124d5bc3a6992088b8bedfef2fa035d5596d8c7": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Bytea" + ] + } + }, + "query": "INSERT INTO \"webauthn\" (\"user_id\", \"name\", \"passkey\") VALUES ($1, $2, $3) RETURNING id" + }, + "12547180eb7b5c0f64bdf5366800aa08f321896adebeef58a47a8e5f456359d7": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Bool", + "Bool", + "Bool", + "Bool", + "Bool" + ] + } + }, + "query": "UPDATE \"webhook\" SET \"url\" = $2, \"description\" = $3, \"token\" = $4, \"enabled\" = $5, \"on_user_created\" = $6, \"on_user_deleted\" = $7, \"on_user_modified\" = $8, \"on_hwkey_provision\" = $9 WHERE id = $1" + }, + "14fc5f06b01eb5329677faddc667915ecb5955cf5ccc817c65ec653c76b6b8f2": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "home_url", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "client_id", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "client_secret", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "enabled", + "ordinal": 7, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT id \"id?\", name, home_url, client_id, client_secret, redirect_uri, description, enabled FROM openidclient WHERE client_id = $1 AND enabled" + }, + "16311b1318c57344286ec65fd32405460028be3466ece9eb0dbb8d738df5e39b": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "address", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "chain_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "challenge_message", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "challenge_signature", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "creation_timestamp", + "ordinal": 7, + "type_info": "Timestamp" + }, + { + "name": "validation_timestamp", + "ordinal": 8, + "type_info": "Timestamp" + }, + { + "name": "use_for_mfa", + "ordinal": 9, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + true, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"user_id\", \"address\", \"name\", \"chain_id\", \"challenge_message\", \"challenge_signature\", \"creation_timestamp\", \"validation_timestamp\", \"use_for_mfa\" FROM \"wallet\" WHERE id = $1" + }, + "169a39acbf7e6b74fd9f538047b0af650fe1bfa21d844a216e80965011290412": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"wallet\" WHERE id = $1" + }, + "16be3197b7d4df94ca7b1805c6d9237b1725ee99c475dcc424f3ffad4a36e94a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Bool", + "Bool", + "Bytea", + { + "Custom": { + "kind": { + "Enum": [ + "none", + "one_time_password", + "webauthn", + "web3" + ] + }, + "name": "mfa_method" + } + }, + "TextArray" + ] + } + }, + "query": "UPDATE \"user\" SET \"username\" = $2, \"password_hash\" = $3, \"last_name\" = $4, \"first_name\" = $5, \"email\" = $6, \"phone\" = $7, \"ssh_key\" = $8, \"pgp_key\" = $9, \"pgp_cert_id\" = $10, \"mfa_enabled\" = $11, \"totp_enabled\" = $12, \"totp_secret\" = $13, \"mfa_method\" = $14, \"recovery_codes\" = $15 WHERE id = $1" + }, + "181648e27fa631654a0068c51bf25ed9c275cc98d718bac2452c6a73090285d4": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text" + ] + } + }, + "query": "INSERT INTO oauth2client (\"user\", client_id, client_secret, redirect_uri, scope) VALUES ($1, $2, $3, $4, $5)" + }, + "18bffe90d894f5a122df97ef37c4afb69f97c74acf36ee7596476ab7fee850b3": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "passkey", + "ordinal": 3, + "type_info": "Bytea" + } + ], + "nullable": [ + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", user_id, name, passkey FROM webauthn WHERE user_id = $1" + }, + "18ffb38cc65309feb0798f5d97368b9112ecfd7cf8a2714d3764e1f100c124f3": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "wireguard_ip", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "wireguard_pubkey", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "created", + "ordinal": 5, + "type_info": "Timestamp" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT device.id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created FROM device WHERE user_id = $1" + }, + "198f23315d696f39da9084de6f20ccbb0c79997cb9c5daaed7c6377f2877669f": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "client_id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "home_url", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "date", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 5, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"user_id\", \"client_id\", \"home_url\", \"date\", \"name\" FROM \"authorizedapps\" WHERE id = $1" + }, + "1b79c9433e20ab471bb1dc4e82224f3063d142b93f6c5fdb4203261b272a8144": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "address: _", + "ordinal": 2, + "type_info": "Inet" + }, + { + "name": "port", + "ordinal": 3, + "type_info": "Int4" + }, + { + "name": "pubkey", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "prvkey", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "endpoint", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "dns", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "allowed_ips: _", + "ordinal": 8, + "type_info": "InetArray" + }, + { + "name": "connected_at", + "ordinal": 9, + "type_info": "Timestamp" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"name\", \"address\" \"address: _\", \"port\", \"pubkey\", \"prvkey\", \"endpoint\", \"dns\", \"allowed_ips\" \"allowed_ips: _\", \"connected_at\" FROM \"wireguard_network\" WHERE id = $1" + }, + "1cb8e98cf71355633ee9f09594c210349dceeeabc7212dc49ac484ba949ec9e5": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Bytea", + "Text" + ] + } + }, + "query": "UPDATE session SET webauthn_challenge = $1 WHERE id = $2" + }, + "2163bfa0df95cc31d0325f2a2a6521ef7e3b701ba5a97a74d5e6b3637c626dda": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"settings\" WHERE id = $1" + }, + "2e73d767e5c98702de3d5340e9f0818f346d9d25547aedd4dc388347b7620cfd": { + "describe": { + "columns": [ + { + "name": "address!", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "chain_id", + "ordinal": 2, + "type_info": "Int8" + }, + { + "name": "use_for_mfa", + "ordinal": 3, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT address \"address!\", name, chain_id, use_for_mfa FROM wallet WHERE user_id = $1 AND validation_timestamp IS NOT NULL" + }, + "306f3e5727878044b9cc9a2bdf250ad2c0008cc2aba333455d413ef2f2d3785c": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "client_id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "home_url", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "date", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 5, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"user_id\", \"client_id\", \"home_url\", \"date\", \"name\" FROM \"authorizedapps\"" + }, + "33813a92407a52297f68cff8c6c3dcd8ae0d54ac743398e9ab3cf9e6a28c3660": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "DELETE FROM group_user WHERE group_id = $1 AND user_id = $2" + }, + "33e91546890789d9341b06c541648149baf908b4f2ba35da8adc07feda6299e3": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "url", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "token", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "enabled", + "ordinal": 4, + "type_info": "Bool" + }, + { + "name": "on_user_created", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "on_user_deleted", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "on_user_modified", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "on_hwkey_provision", + "ordinal": 8, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT id \"id?\", url, description, token, enabled, on_user_created, on_user_deleted, on_user_modified, on_hwkey_provision FROM webhook WHERE url = $1" + }, + "33f8b2b62f7b8c70c1bc115f7cfca5b4cf6d1740ac236286ed152734e108b2e3": { + "describe": { + "columns": [ + { + "name": "access_token", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "refresh_token", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "scope", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "expires_in", + "ordinal": 4, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT access_token, refresh_token, redirect_uri, scope, expires_in FROM oauth2token WHERE access_token = $1" + }, + "3910d049cef41cdd2364eb9da72eef6c40a9bd22ec4b20fe3c5e425252246f77": { + "describe": { + "columns": [ + { + "name": "user", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "client_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "code", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "scope", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "auth_time", + "ordinal": 5, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT \"user\", client_id, code, redirect_uri, scope, auth_time FROM authorization_code WHERE code = $1" + }, + "3a73f9c03328d54bdaa61188dc3a53811defbe0814fabf6b69a81a78d0bdbd95": { + "describe": { + "columns": [ + { + "name": "active_users!", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "active_devices!", + "ordinal": 1, + "type_info": "Int8" + } + ], + "nullable": [ + null, + null + ], + "parameters": { + "Left": [ + "Timestamp" + ] + } + }, + "query": "\n SELECT\n COALESCE(COUNT(DISTINCT(u.id)), 0) as \"active_users!\",\n COALESCE(COUNT(DISTINCT(s.device_id)), 0) as \"active_devices!\"\n FROM \"user\" u\n JOIN device d ON d.user_id = u.id\n JOIN wireguard_peer_stats s ON s.device_id = d.id\n WHERE latest_handshake >= $1\n " + }, + "3abcf090a9adea823eb2288d1379916fce5f172e9f92243c3706f3fac48464ab": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Inet", + "Int4", + "Text", + "Text", + "Text", + "Text", + "InetArray", + "Timestamp" + ] + } + }, + "query": "UPDATE \"wireguard_network\" SET \"name\" = $2, \"address\" = $3, \"port\" = $4, \"pubkey\" = $5, \"prvkey\" = $6, \"endpoint\" = $7, \"dns\" = $8, \"allowed_ips\" = $9, \"connected_at\" = $10 WHERE id = $1" + }, + "3be325a5e6b8ef591fde2e339eee96d6fba101fc81565cffbc26dfb9f8a4fd8d": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"webhook\" WHERE id = $1" + }, + "3e2b0f2fcef97409bdf2f0042d80489b53a488697b3d1f4018e5994278c304a0": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Int8" + ] + } + }, + "query": "INSERT INTO oauth2token (access_token, refresh_token, redirect_uri, scope, expires_in) VALUES ($1, $2, $3, $4, $5)" + }, + "4a50eccb1be2b1721d27b4aaf59108ab739b7cbc9dad8b602297c5cded6bd03e": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "wireguard_ip", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "wireguard_pubkey", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "created", + "ordinal": 5, + "type_info": "Timestamp" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created FROM device WHERE wireguard_pubkey = $1" + }, + "4ae5364b82667472eb900164f048c6678f521c051ea401338480f5e5f0bf21a2": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "device_id!", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "collected_at!", + "ordinal": 2, + "type_info": "Timestamp" + }, + { + "name": "network!", + "ordinal": 3, + "type_info": "Int8" + }, + { + "name": "endpoint", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "upload!", + "ordinal": 5, + "type_info": "Int8" + }, + { + "name": "download!", + "ordinal": 6, + "type_info": "Int8" + }, + { + "name": "latest_handshake!", + "ordinal": 7, + "type_info": "Timestamp" + }, + { + "name": "allowed_ips", + "ordinal": 8, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT id \"id?\", device_id \"device_id!\", collected_at \"collected_at!\", network \"network!\",\n endpoint, upload \"upload!\", download \"download!\", latest_handshake \"latest_handshake!\", allowed_ips\n FROM wireguard_peer_stats\n WHERE device_id = $1\n ORDER BY collected_at DESC\n LIMIT 1\n " + }, + "4e1d29f6440594e4303d9ff7da541b4bca33b44e931126ba8c6b31304a9206b3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Timestamp", + "Int8", + "Text", + "Int8", + "Int8", + "Timestamp", + "Text" + ] + } + }, + "query": "UPDATE \"wireguard_peer_stats\" SET \"device_id\" = $2, \"collected_at\" = $3, \"network\" = $4, \"endpoint\" = $5, \"upload\" = $6, \"download\" = $7, \"latest_handshake\" = $8, \"allowed_ips\" = $9 WHERE id = $1" + }, + "4f07261b2abb336b6da1b921f61c9301abdef3cf8b37fd14b21e9f3a22a67a06": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"openidclient\" WHERE id = $1" + }, + "5200b417e263cf7f124740bae493f09a197c6eebe152c829a7c6551c0c0ed4ed": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"openidclientauthcode\" WHERE id = $1" + }, + "521b6faf2af49533a87fcaff3ab934d4fe8825f6ed040f4a92c195133742e2e8": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "UPDATE \"user\" SET mfa_enabled = FALSE AND recovery_codes = '{}' WHERE id = $1" + }, + "57c6333e11b68c1451f79047aa5449cad8a704aad2756239282738b04ff70fd7": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "web3_enabled", + "ordinal": 1, + "type_info": "Bool" + }, + { + "name": "openid_enabled", + "ordinal": 2, + "type_info": "Bool" + }, + { + "name": "oauth_enabled", + "ordinal": 3, + "type_info": "Bool" + }, + { + "name": "ldap_enabled", + "ordinal": 4, + "type_info": "Bool" + }, + { + "name": "wireguard_enabled", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "webhooks_enabled", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "worker_enabled", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "challenge_template", + "ordinal": 8, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"web3_enabled\", \"openid_enabled\", \"oauth_enabled\", \"ldap_enabled\", \"wireguard_enabled\", \"webhooks_enabled\", \"worker_enabled\", \"challenge_template\" FROM \"settings\" WHERE id = $1" + }, + "57f9cef4b6b89be730168450c5dfe5bac9d102aecdb8c82f940b4f34e2b9ccd2": { + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT \"group\".name FROM \"group\" JOIN group_user ON \"group\".id = group_user.group_id WHERE group_user.user_id = $1" + }, + "59490af08c0b430d7add9c41144af6cce3f9599633a11f28bdde07cb38e99b8b": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "address: _", + "ordinal": 2, + "type_info": "Inet" + }, + { + "name": "port", + "ordinal": 3, + "type_info": "Int4" + }, + { + "name": "pubkey", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "prvkey", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "endpoint", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "dns", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "allowed_ips: _", + "ordinal": 8, + "type_info": "InetArray" + }, + { + "name": "connected_at", + "ordinal": 9, + "type_info": "Timestamp" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"name\", \"address\" \"address: _\", \"port\", \"pubkey\", \"prvkey\", \"endpoint\", \"dns\", \"allowed_ips\" \"allowed_ips: _\", \"connected_at\" FROM \"wireguard_network\"" + }, + "5988d5bb9167f4b7a0695b6efccce0051fcf2267fd095256f482029b3f5a63d5": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "web3_enabled", + "ordinal": 1, + "type_info": "Bool" + }, + { + "name": "openid_enabled", + "ordinal": 2, + "type_info": "Bool" + }, + { + "name": "oauth_enabled", + "ordinal": 3, + "type_info": "Bool" + }, + { + "name": "ldap_enabled", + "ordinal": 4, + "type_info": "Bool" + }, + { + "name": "wireguard_enabled", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "webhooks_enabled", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "worker_enabled", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "challenge_template", + "ordinal": 8, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"web3_enabled\", \"openid_enabled\", \"oauth_enabled\", \"ldap_enabled\", \"wireguard_enabled\", \"webhooks_enabled\", \"worker_enabled\", \"challenge_template\" FROM \"settings\"" + }, + "59c1b85f37143a9573d61d440485bda5dd3b550011fa2486c7003a7f00743255": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text", + "Bytea" + ] + } + }, + "query": "UPDATE \"webauthn\" SET \"user_id\" = $2, \"name\" = $3, \"passkey\" = $4 WHERE id = $1" + }, + "5ecfb41255d2aa4cb98b509fc17122c4032c7b81db6645853c6dcdbcc596c0b6": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "device_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "collected_at", + "ordinal": 2, + "type_info": "Timestamp" + }, + { + "name": "network", + "ordinal": 3, + "type_info": "Int8" + }, + { + "name": "endpoint", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "upload", + "ordinal": 5, + "type_info": "Int8" + }, + { + "name": "download", + "ordinal": 6, + "type_info": "Int8" + }, + { + "name": "latest_handshake", + "ordinal": 7, + "type_info": "Timestamp" + }, + { + "name": "allowed_ips", + "ordinal": 8, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + true + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"device_id\", \"collected_at\", \"network\", \"endpoint\", \"upload\", \"download\", \"latest_handshake\", \"allowed_ips\" FROM \"wireguard_peer_stats\"" + }, + "5f7c021999ce61f8b550d62709c458e2f769c6feec5a7168a0f17f1a7f187e58": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Bool", + "Bool", + "Bool", + "Bool", + "Bool", + "Bool", + "Bool", + "Text" + ] + } + }, + "query": "UPDATE \"settings\" SET \"web3_enabled\" = $2, \"openid_enabled\" = $3, \"oauth_enabled\" = $4, \"ldap_enabled\" = $5, \"wireguard_enabled\" = $6, \"webhooks_enabled\" = $7, \"worker_enabled\" = $8, \"challenge_template\" = $9 WHERE id = $1" + }, + "627ec688631de93ef949e41777f0ea076dcef5e9007466f0553dd1f1df45174a": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "wireguard_ip", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "wireguard_pubkey", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "created", + "ordinal": 5, + "type_info": "Timestamp" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "SELECT device.id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created FROM device JOIN \"user\" ON device.user_id = \"user\".id WHERE device.id = $1 AND \"user\".id = $2" + }, + "66e54bfa4ee59adf2a1dc3b2e454a440617a53d376a21b9dcc7d482de2e8d54f": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "code", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "client_id", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "state", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "scope", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "nonce", + "ordinal": 7, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT id \"id?\", \"user\", code, client_id, state, scope, redirect_uri, nonce FROM openidclientauthcode WHERE code = $1" + }, + "6700901a46e82d30d7586ad3039e9ad91d8ca682491977443d6e28e31513b86d": { + "describe": { + "columns": [ + { + "name": "latest_handshake: NaiveDateTime", + "ordinal": 0, + "type_info": "Timestamp" + } + ], + "nullable": [ + true + ], + "parameters": { + "Left": [ + "Int8", + "Float8" + ] + } + }, + "query": "\n SELECT\n latest_handshake \"latest_handshake: NaiveDateTime\"\n FROM wireguard_peer_stats_view\n WHERE device_id = $1\n AND latest_handshake IS NOT NULL\n AND (latest_handshake_diff > $2 * interval '1 minute' OR latest_handshake_diff IS NULL)\n ORDER BY collected_at DESC\n LIMIT 1\n " + }, + "6b8b18044d42f5f96c243e5060142e260912b1f798cadf53e1df320aa33ceb5c": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text" + ] + } + }, + "query": "UPDATE \"openidclientauthcode\" SET \"user\" = $2, \"code\" = $3, \"client_id\" = $4, \"state\" = $5, \"scope\" = $6, \"redirect_uri\" = $7, \"nonce\" = $8 WHERE id = $1" + }, + "72d23351a50aab914232b485b109ffaaf3abe58e73f8180f9dc0211dd4aa5f53": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "device_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "collected_at", + "ordinal": 2, + "type_info": "Timestamp" + }, + { + "name": "network", + "ordinal": 3, + "type_info": "Int8" + }, + { + "name": "endpoint", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "upload", + "ordinal": 5, + "type_info": "Int8" + }, + { + "name": "download", + "ordinal": 6, + "type_info": "Int8" + }, + { + "name": "latest_handshake", + "ordinal": 7, + "type_info": "Timestamp" + }, + { + "name": "allowed_ips", + "ordinal": 8, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"device_id\", \"collected_at\", \"network\", \"endpoint\", \"upload\", \"download\", \"latest_handshake\", \"allowed_ips\" FROM \"wireguard_peer_stats\" WHERE id = $1" + }, + "77532f50fc8765ac97323cd3df60df8ae443769670c459426d738ac8d4fab6f4": { + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id!\", name FROM webauthn WHERE user_id = $1" + }, + "7cf85946b3bb1f15d1158ccea7b361424056c3532cac1985e65fcdf3eca59242": { + "describe": { + "columns": [ + { + "name": "count!", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT count(*) \"count!\" FROM device" + }, + "7d502cb019e69adc755d828bc581fa56c24c48245689fd21799ec96b1dac10ad": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "username", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "password_hash", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "phone", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "ssh_key", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "pgp_key", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "pgp_cert_id", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "mfa_enabled", + "ordinal": 10, + "type_info": "Bool" + }, + { + "name": "totp_enabled", + "ordinal": 11, + "type_info": "Bool" + }, + { + "name": "totp_secret", + "ordinal": 12, + "type_info": "Bytea" + }, + { + "name": "mfa_method: _", + "ordinal": 13, + "type_info": { + "Custom": { + "kind": { + "Enum": [ + "none", + "one_time_password", + "webauthn", + "web3" + ] + }, + "name": "mfa_method" + } + } + }, + { + "name": "recovery_codes: _", + "ordinal": 14, + "type_info": "TextArray" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true, + false, + false + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"username\", \"password_hash\", \"last_name\", \"first_name\", \"email\", \"phone\", \"ssh_key\", \"pgp_key\", \"pgp_cert_id\", \"mfa_enabled\", \"totp_enabled\", \"totp_secret\", \"mfa_method\" \"mfa_method: _\", \"recovery_codes\" \"recovery_codes: _\" FROM \"user\"" + }, + "7e19c2e4c2abb3b3f75eb0745552dfbfcde10e2f959d92907d58a607bb210bc6": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + } + }, + "query": "DELETE FROM authorization_code WHERE client_id = $1 AND code = $2" + }, + "7e7ec27c0fba3e139e4add5600befe98a10f89eced7daffcbdd1d963264112a1": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + } + }, + "query": "UPDATE session SET web3_challenge = $1 WHERE id = $2" + }, + "80c48375b2eea6f83038d443c0d2d180b1bb35cf84c19eb8ad6b6f8ed8b994e1": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"wireguard_network\" WHERE id = $1" + }, + "84782f5c98572a6689dda1a98d71b749bf6beb025c1e493c1c4183f09aead891": { + "describe": { + "columns": [ + { + "name": "username", + "ordinal": 0, + "type_info": "Text" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT \"user\".username FROM \"user\" JOIN group_user ON \"user\".id = group_user.user_id WHERE group_user.group_id = $1" + }, + "86135388d6da625f594c74860ca50859589f8735396acffa968ce88cfe307ff5": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + } + }, + "query": "UPDATE \"group\" SET \"name\" = $2 WHERE id = $1" + }, + "893d23db987b1d1c3a0a14759327b2945cc8409866177bafbe167279c59a1f22": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "address", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "chain_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "challenge_message", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "challenge_signature", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "creation_timestamp", + "ordinal": 7, + "type_info": "Timestamp" + }, + { + "name": "validation_timestamp", + "ordinal": 8, + "type_info": "Timestamp" + }, + { + "name": "use_for_mfa", + "ordinal": 9, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + true, + false + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"user_id\", \"address\", \"name\", \"chain_id\", \"challenge_message\", \"challenge_signature\", \"creation_timestamp\", \"validation_timestamp\", \"use_for_mfa\" FROM \"wallet\"" + }, + "89ecf876c5ba576acc57f53e273f1e1799ad9cf619a1d31e79de116308f85c30": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + } + }, + "query": "DELETE FROM oauth2token WHERE access_token = $1 AND refresh_token = $2" + }, + "90d962160e9ecd0708e6a722943c1530fcaadeabc516f81a0375146c94787dd3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "INSERT INTO group_user (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING" + }, + "92e3de4ba50a6705c64523dd177351c5fa887d7116dea6d5f194369339aace56": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "UPDATE \"user\" SET totp_enabled = TRUE WHERE id = $1" + }, + "984544838a95d7e90fdd8f2d8108558118167abd49d43e0fb7372df65b427115": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "url", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "token", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "enabled", + "ordinal": 4, + "type_info": "Bool" + }, + { + "name": "on_user_created", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "on_user_deleted", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "on_user_modified", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "on_hwkey_provision", + "ordinal": 8, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"url\", \"description\", \"token\", \"enabled\", \"on_user_created\", \"on_user_deleted\", \"on_user_modified\", \"on_hwkey_provision\" FROM \"webhook\"" + }, + "996fb6d746721e6ff78a3da44b2e5122ee4aaeeeb6c7548f340cff89111fffdb": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "wireguard_ip", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "wireguard_pubkey", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "created", + "ordinal": 5, + "type_info": "Timestamp" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"name\", \"wireguard_ip\", \"wireguard_pubkey\", \"user_id\", \"created\" FROM \"device\" WHERE id = $1" + }, + "9bb9a07281e19a57b10ddfeba1d1eec91021f371d0cf40d6dbdb82e38f28aa04": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"webauthn\" WHERE id = $1" + }, + "9d3693f4dcbc33a5b0af891d2c56644ee595eb4b39cdff43c0416dc7ad35a816": { + "describe": { + "columns": [ + { + "name": "access_token", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "refresh_token", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "scope", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "expires_in", + "ordinal": 4, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT access_token, refresh_token, redirect_uri, scope, expires_in FROM oauth2token WHERE refresh_token = $1" + }, + "9d3f8b67d74981dd83f3f2be9614c6200c0fa4de84ae1acbe37d9864005282da": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "url", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "token", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "enabled", + "ordinal": 4, + "type_info": "Bool" + }, + { + "name": "on_user_created", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "on_user_deleted", + "ordinal": 6, + "type_info": "Bool" + }, + { + "name": "on_user_modified", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "on_hwkey_provision", + "ordinal": 8, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"url\", \"description\", \"token\", \"enabled\", \"on_user_created\", \"on_user_deleted\", \"on_user_modified\", \"on_hwkey_provision\" FROM \"webhook\" WHERE id = $1" + }, + "9ea6f3e288d0a23c2b020034c80e60c0c73bcc37705cf408ea14c65ec3d1dab8": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"wireguard_peer_stats\" WHERE id = $1" + }, + "9f7a75ac4a3c5b767746a836e409e3e3f02a029cf9456bed8c5291f92f1d5f8a": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "address", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "chain_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "challenge_message", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "challenge_signature", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "creation_timestamp", + "ordinal": 7, + "type_info": "Timestamp" + }, + { + "name": "validation_timestamp", + "ordinal": 8, + "type_info": "Timestamp" + }, + { + "name": "use_for_mfa", + "ordinal": 9, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + true, + false + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + } + }, + "query": "SELECT id \"id?\", user_id, address, name, chain_id, challenge_message, challenge_signature, creation_timestamp, validation_timestamp, use_for_mfa FROM wallet WHERE user_id = $1 AND address = $2" + }, + "a28203258ea19229b180817c5419c516dd15457db05f436c96ef5287f94daf11": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8" + ] + } + }, + "query": "UPDATE oauth2token SET access_token = $2, refresh_token = $3, expires_in = $4 WHERE access_token = $1" + }, + "a62684020a138be9e8d7a4fba3b74cff25b549008c718ea53922443683f26f72": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "client_id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "home_url", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "date", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 5, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + } + }, + "query": "SELECT id \"id?\", user_id, client_id, home_url, date, name FROM authorizedapps WHERE user_id = $1 AND client_id = $2" + }, + "a85ff25c6803ab54c4a297fa946e2f469a9599d734163613c7ea7fa125c25148": { + "describe": { + "columns": [ + { + "name": "collected_at: NaiveDateTime", + "ordinal": 0, + "type_info": "Timestamp" + }, + { + "name": "upload", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "download", + "ordinal": 2, + "type_info": "Int8" + } + ], + "nullable": [ + null, + null, + null + ], + "parameters": { + "Left": [ + "Text", + "Timestamp", + "Int8" + ] + } + }, + "query": "\n SELECT\n date_trunc($1, collected_at) \"collected_at: NaiveDateTime\",\n cast(sum(upload) AS bigint) upload, cast(sum(download) AS bigint) download\n FROM wireguard_peer_stats_view\n WHERE collected_at >= $2\n GROUP BY 1\n ORDER BY 1\n LIMIT $3\n " + }, + "a8f720a41ab4509570490d444180ae58f20596491a3ac2be3ab00a8a19c78c58": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int2", + "Timestamp", + "Timestamp", + "Bytea", + "Text" + ] + } + }, + "query": "INSERT INTO session (id, user_id, state, created, expires, webauthn_challenge, web3_challenge) VALUES ($1, $2, $3, $4, $5, $6, $7)" + }, + "aae8d76f39d8a5149050df73b145963cc83bb2bcef4f137bb032e1f46704984d": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Bool", + "Bool", + "Bool", + "Bool", + "Bool" + ] + } + }, + "query": "INSERT INTO \"webhook\" (\"url\", \"description\", \"token\", \"enabled\", \"on_user_created\", \"on_user_deleted\", \"on_user_modified\", \"on_hwkey_provision\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id" + }, + "ac9fdeb2a7c0d6b81f3f5e7ad2cc6f73e07a897def131f93dc5d616dfd8c032f": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "home_url", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "client_id", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "client_secret", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "enabled", + "ordinal": 7, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"name\", \"description\", \"home_url\", \"client_id\", \"client_secret\", \"redirect_uri\", \"enabled\" FROM \"openidclient\"" + }, + "b006fc5fe27302b8ac23eb940798a024ee27403f32a10181051672b80e8190ef": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "code", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "client_id", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "state", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "scope", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "nonce", + "ordinal": 7, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"user\", \"code\", \"client_id\", \"state\", \"scope\", \"redirect_uri\", \"nonce\" FROM \"openidclientauthcode\" WHERE id = $1" + }, + "b2410cc60e0e1dcacd03050d2a3ac417ca5822beebe08be3f23cc0e217e27740": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"name\" FROM \"group\" WHERE id = $1" + }, + "b2d78282574e9c4fa14d49c6c15c1f1e4cc588b372c7877329a8f6322b23ba24": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Text", + "Inet", + "Int4", + "Text", + "Text", + "Text", + "Text", + "InetArray", + "Timestamp" + ] + } + }, + "query": "INSERT INTO \"wireguard_network\" (\"name\", \"address\", \"port\", \"pubkey\", \"prvkey\", \"endpoint\", \"dns\", \"allowed_ips\", \"connected_at\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id" + }, + "b33fa60772e858280c021b3774d01273453d13af4321cae2d244748ca4254b29": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT id \"id?\", name FROM \"group\" WHERE name = $1" + }, + "b537fa02e7d93c86fc31189844fabf6e6fff37f3567c315dee98e0913a387c2d": { + "describe": { + "columns": [ + { + "name": "user", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "client_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "client_secret", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "scope", + "ordinal": 4, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT \"user\", client_id, client_secret, redirect_uri, scope FROM oauth2client WHERE client_id = $1" + }, + "b57f8562cff6e1d13d6ffa0e7a382fda523559100424c771e25757994a1fca13": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int2", + "Text" + ] + } + }, + "query": "UPDATE session SET state = $1 WHERE id = $2" + }, + "b8656ec4ee5b1980223294cad758ea2da20b6cfc25f51a25023d69a4595a4fb9": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Bytea", + "Int8" + ] + } + }, + "query": "UPDATE \"user\" SET totp_secret = $1 WHERE id = $2" + }, + "bb793a93f5810f6c1e6bfbada1b5d5d9ffab7c248d2b484ef471d67914a7fb72": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "wireguard_ip", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "wireguard_pubkey", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "created", + "ordinal": 5, + "type_info": "Timestamp" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"name\", \"wireguard_ip\", \"wireguard_pubkey\", \"user_id\", \"created\" FROM \"device\"" + }, + "bd814a4cae35fce9b8a8967e800c889cdfb836834beb17974e0ee115bb7654b7": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "passkey", + "ordinal": 3, + "type_info": "Bytea" + } + ], + "nullable": [ + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"user_id\", \"name\", \"passkey\" FROM \"webauthn\" WHERE id = $1" + }, + "bd9520722a8392da2bd841e5071400e1658e8d26a73542fac8d8a214009888b7": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "state: SessionState", + "ordinal": 2, + "type_info": "Int2" + }, + { + "name": "created", + "ordinal": 3, + "type_info": "Timestamp" + }, + { + "name": "expires", + "ordinal": 4, + "type_info": "Timestamp" + }, + { + "name": "webauthn_challenge", + "ordinal": 5, + "type_info": "Bytea" + }, + { + "name": "web3_challenge", + "ordinal": 6, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT id, user_id, state \"state: SessionState\", created, expires, webauthn_challenge, web3_challenge FROM session WHERE id = $1" + }, + "c6bce4888a517be6fa0d6c9dd21c681f60bc711dfb0c6f85529a4eef62e6c165": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Int8", + "Timestamp" + ] + } + }, + "query": "UPDATE \"device\" SET \"name\" = $2, \"wireguard_ip\" = $3, \"wireguard_pubkey\" = $4, \"user_id\" = $5, \"created\" = $6 WHERE id = $1" + }, + "c764d846599198933ee52cd6207c9ade98383d59be656bdee566bd795f78c2db": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "client_id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "home_url", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "date", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 5, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", user_id, client_id, home_url, date, name FROM authorizedapps WHERE user_id = $1" + }, + "c7d2ef370d6f6a99164886eff3e6071bc02ab3eb35c603713a00126af6ddb6d6": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text" + ] + } + }, + "query": "INSERT INTO \"openidclientauthcode\" (\"user\", \"code\", \"client_id\", \"state\", \"scope\", \"redirect_uri\", \"nonce\") VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id" + }, + "c863944a13f1b8c3eb0d5a62bff40f453da7ac74551c2e62275931a1e8a7ec9e": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "username", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "password_hash", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "phone", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "ssh_key", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "pgp_key", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "pgp_cert_id", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "mfa_enabled", + "ordinal": 10, + "type_info": "Bool" + }, + { + "name": "totp_enabled", + "ordinal": 11, + "type_info": "Bool" + }, + { + "name": "totp_secret", + "ordinal": 12, + "type_info": "Bytea" + }, + { + "name": "mfa_method: _", + "ordinal": 13, + "type_info": { + "Custom": { + "kind": { + "Enum": [ + "none", + "one_time_password", + "webauthn", + "web3" + ] + }, + "name": "mfa_method" + } + } + }, + { + "name": "recovery_codes", + "ordinal": 14, + "type_info": "TextArray" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true, + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT id \"id?\", username, password_hash, last_name, first_name, email, phone, ssh_key, pgp_key, pgp_cert_id, mfa_enabled, totp_enabled, totp_secret, mfa_method \"mfa_method: _\", recovery_codes FROM \"user\" WHERE username = $1" + }, + "c8aa12003d91b6fc3fcd225bc260be4e51297101ebb0a02215245676cd708ab0": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "wireguard_ip", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "wireguard_pubkey", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "created", + "ordinal": 5, + "type_info": "Timestamp" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT device.id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created FROM device JOIN \"user\" ON device.user_id = \"user\".id WHERE \"user\".username = $1" + }, + "cc02ddc8c954d28c58a443ba7c86def4b1134177e4a5c5ab661667eeb5da5ed7": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Int8" + ] + } + }, + "query": "INSERT INTO authorization_code (\"user\", client_id, code, redirect_uri, scope, auth_time) VALUES ($1, $2, $3, $4, $5, $6)" + }, + "cc2cc1d23dfcb6fb23cfa4631b2264e4542358d5aad1096bc998b33d949324eb": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Bool", + "Bool", + "Bool", + "Bool", + "Bool", + "Bool", + "Bool", + "Text" + ] + } + }, + "query": "INSERT INTO \"settings\" (\"web3_enabled\", \"openid_enabled\", \"oauth_enabled\", \"ldap_enabled\", \"wireguard_enabled\", \"webhooks_enabled\", \"worker_enabled\", \"challenge_template\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id" + }, + "cd921af31e4d0d281fc06e3904e2f8a29f8fc8a84902b5a9cc65392523cb3735": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text", + "Text", + "Text", + "Text" + ] + } + }, + "query": "UPDATE \"authorizedapps\" SET \"user_id\" = $2, \"client_id\" = $3, \"home_url\" = $4, \"date\" = $5, \"name\" = $6 WHERE id = $1" + }, + "d7682c748095810c88a420da7596d68b3da6c58538f541a901d027d17f50fd16": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"authorizedapps\" WHERE id = $1" + }, + "d98e365929bb0fbf3915b517db93168f76f9a0c4832cd2efd13d74268cc435b8": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + } + }, + "query": "UPDATE \"user\" SET recovery_codes = $2 WHERE id = $1" + }, + "dab9a6b3dc5d3f57c0f49f1f111db88c8c5658ea7d1492e1922820bbebc51209": { + "describe": { + "columns": [ + { + "name": "passkey", + "ordinal": 0, + "type_info": "Bytea" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT passkey FROM webauthn WHERE user_id = $1" + }, + "dea8e678aa8f367d359afb80663c56b9429176e3dba0c2db7c224efa430c5fc4": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "username", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "password_hash", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "phone", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "ssh_key", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "pgp_key", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "pgp_cert_id", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "mfa_enabled", + "ordinal": 10, + "type_info": "Bool" + }, + { + "name": "totp_enabled", + "ordinal": 11, + "type_info": "Bool" + }, + { + "name": "totp_secret", + "ordinal": 12, + "type_info": "Bytea" + }, + { + "name": "mfa_method: _", + "ordinal": 13, + "type_info": { + "Custom": { + "kind": { + "Enum": [ + "none", + "one_time_password", + "webauthn", + "web3" + ] + }, + "name": "mfa_method" + } + } + }, + { + "name": "recovery_codes: _", + "ordinal": 14, + "type_info": "TextArray" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"username\", \"password_hash\", \"last_name\", \"first_name\", \"email\", \"phone\", \"ssh_key\", \"pgp_key\", \"pgp_cert_id\", \"mfa_enabled\", \"totp_enabled\", \"totp_secret\", \"mfa_method\" \"mfa_method: _\", \"recovery_codes\" \"recovery_codes: _\" FROM \"user\" WHERE id = $1" + }, + "e34c2a7ac137329f1a9813db3f1ebbc837971d9c64ee2a6d7d902c1a0f8d6c05": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"device\" WHERE id = $1" + }, + "e4804e2027fa8c4885cdc643840b2fd84274c0627d6c7401d1dc8608cae50756": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "home_url", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "client_id", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "client_secret", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "enabled", + "ordinal": 7, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT id \"id?\", \"name\", \"description\", \"home_url\", \"client_id\", \"client_secret\", \"redirect_uri\", \"enabled\" FROM \"openidclient\" WHERE id = $1" + }, + "e5624c65ab8248f1fcf973c0c7501792d53748dbd5d227e492527f07ca194fc6": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "DELETE FROM session WHERE id = $1" + }, + "e6baa94aed2de495bad45e2b7b2464c1de949bf4973c0dbcad906e25cde8c2e3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "DELETE FROM \"user\" WHERE id = $1" + }, + "e8bf23addc2761e39cd5215321d246e31199c48625e03762dda4a34c2930b6be": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "wireguard_ip", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "wireguard_pubkey", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "created", + "ordinal": 5, + "type_info": "Timestamp" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + } + }, + "query": "SELECT device.id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created FROM device JOIN \"user\" ON device.user_id = \"user\".id WHERE device.id = $1 AND \"user\".username = $2" + }, + "e99464b4da5a21bcffda16866783d11c819eb078e392f1492467ae420800242d": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "INSERT INTO \"group\" (\"name\") VALUES ($1) RETURNING id" + }, + "eb00cea26638259fe12354abc085bbf893f10e62afb409fb0f198d33aadf1497": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Bool", + "Bool", + "Bytea", + { + "Custom": { + "kind": { + "Enum": [ + "none", + "one_time_password", + "webauthn", + "web3" + ] + }, + "name": "mfa_method" + } + }, + "TextArray" + ] + } + }, + "query": "INSERT INTO \"user\" (\"username\", \"password_hash\", \"last_name\", \"first_name\", \"email\", \"phone\", \"ssh_key\", \"pgp_key\", \"pgp_cert_id\", \"mfa_enabled\", \"totp_enabled\", \"totp_secret\", \"mfa_method\", \"recovery_codes\") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id" + }, + "ee5eca69f32bc6b71279d207a9d6cc1ceda31120d1858042c10743f6929e5903": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "code", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "client_id", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "state", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "scope", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "redirect_uri", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "nonce", + "ordinal": 7, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"user\", \"code\", \"client_id\", \"state\", \"scope\", \"redirect_uri\", \"nonce\" FROM \"openidclientauthcode\"" + }, + "eee2734f98b411d6fc75bf92027d84b8ca5e41de023a473681c076d6cc072ab2": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [] + } + }, + "query": "DELETE FROM session WHERE expires < now()" + }, + "f389f0e8755f4e2d6ecf630869ca7d36f4c84be8a7042ae89e3aa103c029fa9c": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Bool" + ] + } + }, + "query": "INSERT INTO \"openidclient\" (\"name\", \"description\", \"home_url\", \"client_id\", \"client_secret\", \"redirect_uri\", \"enabled\") VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id" + }, + "f444433840871aff81db51bbf1b615e64018be82154fd655509a365ca91d3e65": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "wireguard_ip", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "wireguard_pubkey", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "created", + "ordinal": 5, + "type_info": "Timestamp" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "SELECT id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created FROM device WHERE wireguard_ip = $1" + }, + "f5a52d311dd0ac4c76cf1638f30cffd8c97fe2b4987fcfb9f9ca523ab6e5bf5d": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Timestamp", + "Int8" + ] + } + }, + "query": "UPDATE wallet SET challenge_signature = $1, validation_timestamp = $2 WHERE id = $3" + }, + "f72072385b9966b9fe82af26eb166877d001bb3a723b396f8ab5cacea0e3220d": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "UPDATE \"user\" SET totp_enabled = FALSE AND totp_secret = NULL WHERE id = $1" + }, + "fd9c7f73acaec83bb9eb5aa48cf06760b775afd1253e6e5cef1b42d6ca04e04e": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "wireguard_ip", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "wireguard_pubkey", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "created", + "ordinal": 5, + "type_info": "Timestamp" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Timestamp" + ] + } + }, + "query": "\n WITH s AS (\n SELECT DISTINCT ON (device_id) *\n FROM wireguard_peer_stats\n ORDER BY device_id, latest_handshake DESC\n )\n SELECT\n d.id \"id?\", d.name, d.wireguard_ip, d.wireguard_pubkey, d.user_id, d.created\n FROM device d\n JOIN s ON d.id = s.device_id\n WHERE s.latest_handshake > $1\n " + }, + "fdbd4d1e1b8634719de4ea543e51943e5d1d1c1ec63c62e22356d57ffa20ed02": { + "describe": { + "columns": [ + { + "name": "id?", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "passkey", + "ordinal": 3, + "type_info": "Bytea" + } + ], + "nullable": [ + false, + false, + false, + false + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT id \"id?\", \"user_id\", \"name\", \"passkey\" FROM \"webauthn\"" + } +} \ No newline at end of file diff --git a/src/appstate.rs b/src/appstate.rs new file mode 100644 index 000000000..217bacd5e --- /dev/null +++ b/src/appstate.rs @@ -0,0 +1,100 @@ +use crate::{ + config::DefGuardConfig, + db::{AppEvent, DbPool, GatewayEvent, WebHook}, + license::License, +}; +use reqwest::Client; +use rocket::serde::json::serde_json::json; +use tokio::{ + sync::mpsc::{UnboundedReceiver, UnboundedSender}, + task::spawn, +}; +use webauthn_rs::prelude::*; + +pub struct AppState { + pub config: DefGuardConfig, + pub pool: DbPool, + tx: UnboundedSender, + wireguard_tx: UnboundedSender, + pub license: License, + pub webauthn: Webauthn, +} + +impl AppState { + pub fn trigger_action(&self, event: AppEvent) { + let event_name = event.name().to_owned(); + match self.tx.send(event) { + Ok(_) => info!("Sent trigger {}", event_name), + Err(err) => error!("Error sending trigger {}: {}", event_name, err), + } + } + + async fn handle_triggers(pool: DbPool, mut rx: UnboundedReceiver) { + let reqwest_client = Client::builder().user_agent("reqwest").build().unwrap(); + while let Some(msg) = rx.recv().await { + debug!("WebHook triggered"); + if let Ok(webhooks) = WebHook::all_enabled(&pool, &msg).await { + let (payload, event) = match msg { + AppEvent::UserCreated(user) => (json!(user), "user_created"), + AppEvent::UserModified(user) => (json!(user), "user_modified"), + AppEvent::UserDeleted(username) => { + (json!({ "username": username }), "user_deleted") + } + AppEvent::HWKeyProvision(data) => (json!(data), "user_keys"), + }; + for webhook in webhooks { + match reqwest_client + .get(&webhook.url) + .bearer_auth(&webhook.token) + .header("X-DefGuard-Event", event) + .json(&payload) + .send() + .await + { + Ok(res) => { + info!("Trigger sent to {}, status {}", webhook.url, res.status()); + } + Err(err) => { + error!("Error sending trigger to {}: {}", webhook.url, err); + } + } + } + } + } + } + + /// Sends given `GatewayEvent` to be handled by gateway GRPC server + pub fn send_wireguard_event(&self, event: GatewayEvent) { + if let Err(err) = self.wireguard_tx.send(event) { + error!("Error sending wireguard event {}", err); + } + } + + /// Create application state + pub async fn new( + config: DefGuardConfig, + pool: DbPool, + tx: UnboundedSender, + rx: UnboundedReceiver, + wireguard_tx: UnboundedSender, + license: License, + ) -> Self { + spawn(Self::handle_triggers(pool.clone(), rx)); + + let rp_origin = Url::parse(&config.url).expect("Invalid relying party origin"); + let webauthn_builder = WebauthnBuilder::new(&config.webauthn_rp_id, &rp_origin) + .expect("Invalid WebAuthn configuration"); + let webauthn = webauthn_builder + .build() + .expect("Invalid WebAuthn configuration"); + + Self { + config, + pool, + tx, + wireguard_tx, + license, + webauthn, + } + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 000000000..bc87727b4 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,234 @@ +use crate::{ + appstate::AppState, + db::{Session, SessionState, User}, + error::OriWebError, +}; +use jsonwebtoken::{ + decode, encode, errors::Error as JWTError, DecodingKey, EncodingKey, Header, Validation, +}; +use rocket::{ + http::{Cookie, Status}, + outcome::{try_outcome, Outcome}, + request::{self, FromRequest, Request}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + env, + time::{Duration, SystemTime}, +}; + +pub static JWT_ISSUER: &str = "DefGuard"; +pub static SECRET_ENV: &str = "DEFGUARD_JWT_SECRET"; +pub const SESSION_TIMEOUT: u64 = 3600 * 24 * 7; +pub const TOTP_CODE_VALIDITY_PERIOD: u64 = 30; + +#[derive(Deserialize, PartialEq, Serialize)] +pub enum ClaimRole { + Admin, +} + +// Standard claims: https://www.iana.org/assignments/jwt/jwt.xhtml +#[derive(Deserialize, Serialize)] +pub struct Claims { + // issuer + pub iss: String, + // subject + pub sub: String, + // client identifier + pub client_id: String, + // expiration time + pub exp: u64, + // not before + pub nbf: u64, + // roles + #[serde(default)] + pub roles: Vec, +} + +impl Claims { + #[must_use] + pub fn new(sub: String, client_id: String, duration: u64) -> Self { + let now = SystemTime::now(); + let exp = now + .checked_add(Duration::from_secs(duration)) + .expect("valid time") + .duration_since(SystemTime::UNIX_EPOCH) + .expect("valid timestamp") + .as_secs(); + let nbf = now + .duration_since(SystemTime::UNIX_EPOCH) + .expect("valid timestamp") + .as_secs(); + Self { + iss: JWT_ISSUER.to_string(), + sub, + client_id, + exp, + nbf, + roles: Vec::new(), + } + } + + /// Convert claims to JWT. + pub fn to_jwt(&self) -> Result { + let secret = env::var(SECRET_ENV).unwrap_or_default(); + encode( + &Header::default(), + self, + &EncodingKey::from_secret(secret.as_bytes()), + ) + } + + /// Verify JWT and, if successful, convert it to claims. + pub fn from_jwt(token: &str) -> Result { + let secret = env::var(SECRET_ENV).unwrap_or_default(); + let mut validation = Validation::default(); + validation.validate_nbf = true; + validation.set_issuer(&[JWT_ISSUER]); + validation.set_required_spec_claims(&["iss", "sub", "exp", "nbf"]); + decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .map(|data| data.claims) + } + + #[must_use] + pub fn is_admin(&self) -> bool { + self.roles.contains(&ClaimRole::Admin) + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for Session { + type Error = OriWebError; + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + if let Some(state) = request.rocket().state::() { + let cookies = request.cookies(); + if let Some(session_cookie) = cookies.get("session") { + return { + match Session::find_by_id(&state.pool, session_cookie.value()).await { + Ok(Some(session)) => { + if session.expired() { + let _result = session.delete(&state.pool).await; + cookies.remove(Cookie::named("session")); + Outcome::Failure(( + Status::Unauthorized, + OriWebError::Authorization("Session expired".into()), + )) + } else { + Outcome::Success(session) + } + } + Ok(None) => Outcome::Failure(( + Status::Unauthorized, + OriWebError::Authorization("Session not found".into()), + )), + Err(err) => Outcome::Failure((Status::InternalServerError, err.into())), + } + }; + } + } + Outcome::Failure(( + Status::Unauthorized, + OriWebError::Authorization("Session is required".into()), + )) + } +} + +pub struct SessionInfo { + pub user: User, + pub is_admin: bool, +} + +impl SessionInfo { + #[must_use] + pub fn new(user: User, is_admin: bool) -> Self { + Self { user, is_admin } + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for SessionInfo { + type Error = OriWebError; + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + if let Some(state) = request.rocket().state::() { + let user = { + if let Some(token) = request + .headers() + .get_one("Authorization") + .and_then(|value| { + if value.starts_with("Bearer ") { + value.get(7..) + } else { + None + } + }) + { + match Claims::from_jwt(token) { + Ok(claims) => User::find_by_username(&state.pool, &claims.sub).await, + Err(_) => { + return Outcome::Failure(( + Status::Unauthorized, + OriWebError::Authorization("Invalid token".into()), + )); + } + } + } else { + let session = try_outcome!(request.guard::().await); + let user = User::find_by_id(&state.pool, session.user_id).await; + if let Ok(Some(user)) = &user { + if user.mfa_enabled && session.state != SessionState::MultiFactorVerified { + return Outcome::Failure(( + Status::Unauthorized, + OriWebError::Authorization("MFA not verified".into()), + )); + } + } + user + } + }; + + return match user { + Ok(Some(user)) => { + let is_admin = match user.member_of(&state.pool).await { + Ok(groups) => groups.contains(&state.config.admin_groupname), + _ => false, + }; + Outcome::Success(SessionInfo::new(user, is_admin)) + } + _ => Outcome::Failure(( + Status::Unauthorized, + OriWebError::Authorization("User not found".into()), + )), + }; + } + + Outcome::Failure(( + Status::Unauthorized, + OriWebError::Authorization("Invalid session".into()), + )) + } +} + +pub struct AdminRole; + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AdminRole { + type Error = OriWebError; + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + let session_info = try_outcome!(request.guard::().await); + if session_info.is_admin { + Outcome::Success(AdminRole {}) + } else { + Outcome::Failure(( + Status::Forbidden, + OriWebError::Forbidden("access denied".into()), + )) + } + } +} diff --git a/src/bin/defguard.rs b/src/bin/defguard.rs new file mode 100644 index 000000000..97d7258f3 --- /dev/null +++ b/src/bin/defguard.rs @@ -0,0 +1,83 @@ +use clap::Parser; +use defguard::{ + config::{Command, DefGuardConfig}, + db::{init_db, AppEvent, GatewayEvent}, + enterprise::grpc::WorkerState, + grpc::run_grpc_server, + init_dev_env, run_web_server, +}; +use fern::{ + colors::{Color, ColoredLevelConfig}, + Dispatch, +}; +use log::{LevelFilter, SetLoggerError}; +use std::{ + fs::read_to_string, + str::FromStr, + sync::{Arc, Mutex}, +}; +use tokio::sync::mpsc::unbounded_channel; + +/// Configures fern logging library. +fn logger_setup(log_level: &str) -> Result<(), SetLoggerError> { + let colors = ColoredLevelConfig::new() + .trace(Color::BrightWhite) + .debug(Color::BrightCyan) + .info(Color::BrightGreen) + .warn(Color::BrightYellow) + .error(Color::BrightRed); + Dispatch::new() + .format(move |out, message, record| { + out.finish(format_args!( + "[{}][{}][{}] {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"), + colors.color(record.level()), + record.target(), + message + )); + }) + .level(LevelFilter::from_str(log_level).unwrap_or(LevelFilter::Info)) + .level_for("sqlx", LevelFilter::Warn) + .chain(std::io::stdout()) + .apply() +} + +#[tokio::main] +async fn main() -> Result<(), SetLoggerError> { + let config = DefGuardConfig::parse(); + logger_setup(&config.log_level)?; + + if let Some(Command::InitDevEnv) = config.cmd { + init_dev_env(&config).await; + return Ok(()); + } + + let (webhook_tx, webhook_rx) = unbounded_channel::(); + let (wireguard_tx, wireguard_rx) = unbounded_channel::(); + let worker_state = Arc::new(Mutex::new(WorkerState::new(webhook_tx.clone()))); + let pool = init_db( + &config.database_host, + config.database_port, + &config.database_name, + &config.database_user, + &config.database_password, + ) + .await; + + // read grpc TLS cert and key + let grpc_cert = config + .grpc_cert + .as_ref() + .and_then(|path| read_to_string(path).ok()); + let grpc_key = config + .grpc_key + .as_ref() + .and_then(|path| read_to_string(path).ok()); + + // run services + tokio::select! { + _ = run_grpc_server(config.grpc_port, Arc::clone(&worker_state), wireguard_rx, pool.clone(), grpc_cert, grpc_key) => (), + _ = run_web_server(config, worker_state, webhook_tx, webhook_rx, wireguard_tx, pool) => (), + }; + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 000000000..447017e3b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,139 @@ +use clap::Parser; + +#[derive(Clone, Parser)] +pub struct DefGuardConfig { + #[clap(long, env = "DEFGUARD_LOG_LEVEL", default_value = "info")] + pub log_level: String, + + #[clap(long, env = "DEFGUARD_DB_HOST", default_value = "localhost")] + pub database_host: String, + + #[clap(long, env = "DEFGUARD_DB_PORT", default_value_t = 5432)] + pub database_port: u16, + + #[clap(long, env = "DEFGUARD_DB_NAME", default_value = "defguard")] + pub database_name: String, + + #[clap(long, env = "DEFGUARD_DB_USER", default_value = "defguard")] + pub database_user: String, + + #[clap(long, env = "DEFGUARD_DB_PASSWORD", default_value = "")] + pub database_password: String, + + #[clap(long, env = "DEFGUARD_HTTP_PORT", default_value_t = 8000)] + pub http_port: u16, + + #[clap(long, env = "DEFGUARD_OAUTH_ENABLED")] + pub oauth_enabled: bool, + + #[clap(long, env = "DEFGUARD_GRPC_PORT", default_value_t = 50055)] + pub grpc_port: u16, + + #[clap(long, env = "DEFGUARD_GRPC_CERT")] + pub grpc_cert: Option, + + #[clap(long, env = "DEFGUARD_GRPC_KEY")] + pub grpc_key: Option, + + #[clap(long, env = "DEFGUARD_ADMIN_GROUPNAME", default_value = "admin")] + pub admin_groupname: String, + + // relying party id and relying party origin for WebAuthn + #[clap(long, env = "DEFGUARD_WEBAUTHN_RP_ID", default_value = "localhost")] + pub webauthn_rp_id: String, + #[clap(long, env = "DEFGUARD_URL", default_value = "http://localhost:8080")] + pub url: String, + + #[clap( + long, + env = "DEFGUARD_LDAP_URL", + default_value = "ldap://localhost:389" + )] + pub ldap_url: String, + + #[clap( + long, + env = "DEFGUARD_BIND_USERNAME", + default_value = "dc=admin,dc=example,dc=org" + )] + pub ldap_bind_username: String, + + #[clap(long, env = "DEFGUARD_BIND_PASSWORD", default_value = "")] + pub ldap_bind_password: String, + + #[clap( + long, + env = "DEFGUARD_LDAP_USER_SEARCH_BASE", + default_value = "dc=example,dc=org" + )] + pub ldap_user_search_base: String, + + #[clap( + long, + env = "DEFGUARD_LDAP_GROUP_SEARCH_BASE", + default_value = "ou=groups,dc=example,dc=org" + )] + pub ldap_group_search_base: String, + + #[clap( + long, + env = "DEFGUARD_LDAP_USER_OBJ_CLASS", + default_value = "inetOrgPerson" + )] + pub ldap_user_obj_class: String, + + #[clap( + long, + env = "DEFGUARD_LDAP_GROUP_OBJ_CLASS", + default_value = "groupOfUniqueNames" + )] + pub ldap_group_obj_class: String, + + #[clap(long, env = "DEFGUARD_LDAP_USERNAME_ATTR", default_value = "cn")] + pub ldap_username_attr: String, + + #[clap(long, env = "DEFGUARD_LDAP_GROUPNAME_ATTR", default_value = "cn")] + pub ldap_groupname_attr: String, + + #[clap(long, env = "DEFGUARD_LDAP_MEMBER_ATTR", default_value = "memberOf")] + pub ldap_member_attr: String, + + #[clap(long, env = "DEFGUARD_LICENSE", default_value = "")] + pub license: String, + + #[clap( + long, + env = "DEFGUARD_LDAP_GROUP_MEMBER_ATTR", + default_value = "uniqueMember" + )] + pub ldap_group_member_attr: String, + + #[clap(subcommand)] + pub cmd: Option, +} + +#[derive(Clone, Parser)] +pub enum Command { + #[clap( + about = "Initialize development environment. Inserts test network and device into database." + )] + InitDevEnv, +} + +impl DefGuardConfig { + /// Constructs user distinguished name. + pub fn user_dn(&self, username: &str) -> String { + format!( + "{}={},{}", + &self.ldap_username_attr, username, &self.ldap_user_search_base + ) + } + + /// Constructs group distinguished name. + pub fn group_dn(&self, groupname: &str) -> String { + format!( + "{}={},{}", + &self.ldap_groupname_attr, groupname, &self.ldap_group_search_base + ) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 000000000..33771cd8d --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,36 @@ +pub mod models; + +use sqlx::postgres::PgConnectOptions; + +pub type DbPool = sqlx::postgres::PgPool; + +/// Initializes and migrates postgres database. Returns DB pool object. +pub async fn init_db(host: &str, port: u16, name: &str, user: &str, password: &str) -> DbPool { + let opts = PgConnectOptions::new() + .host(host) + .port(port) + .username(user) + .password(password) + .database(name); + let pool = DbPool::connect_with(opts) + .await + .expect("Database connection failed"); + sqlx::migrate!() + .run(&pool) + .await + .expect("Cannot run database migrations."); + pool +} + +pub use models::{ + device::{AddDevice, Device}, + group::Group, + session::{Session, SessionState}, + settings::Settings, + user::User, + wallet::Wallet, + webauthn::WebAuthn, + webhook::{AppEvent, HWKeyUserData, WebHook}, + wireguard::{GatewayEvent, WireguardNetwork, WireguardPeerStats}, + UserInfo, +}; diff --git a/src/db/models/device.rs b/src/db/models/device.rs new file mode 100644 index 000000000..1df189694 --- /dev/null +++ b/src/db/models/device.rs @@ -0,0 +1,190 @@ +use super::{error::ModelError, wireguard::WireguardNetwork, DbPool}; +use chrono::{NaiveDateTime, Utc}; +use ipnetwork::IpNetwork; +use model_derive::Model; +use sqlx::{query_as, Error as SqlxError}; + +#[derive(Clone, Deserialize, Model, Serialize, Debug)] +pub struct Device { + pub id: Option, + pub name: String, + pub wireguard_ip: String, + pub wireguard_pubkey: String, + pub user_id: i64, + pub created: NaiveDateTime, +} + +#[derive(Deserialize)] +pub struct AddDevice { + pub name: String, + pub wireguard_pubkey: String, +} + +impl Device { + #[must_use] + pub fn new(name: String, wireguard_ip: String, wireguard_pubkey: String, user_id: i64) -> Self { + Self { + id: None, + name, + wireguard_ip, + wireguard_pubkey, + user_id, + created: Utc::now().naive_utc(), + } + } + + // FIXME: `other` should be a different struct + pub fn update_from(&mut self, other: Self) { + self.name = other.name; + self.wireguard_ip = other.wireguard_ip; + self.wireguard_pubkey = other.wireguard_pubkey; + } + + pub fn create_config(&self, network: WireguardNetwork) -> String { + let dns = match network.dns { + Some(dns) => { + if dns.is_empty() { + String::new() + } else { + format!("DNS = {}", dns) + } + } + None => String::new(), + }; + let allowed_ips = network + .allowed_ips + .iter() + .map(IpNetwork::to_string) + .collect::>() + .join(","); + format!( + "[Interface]\n\ + PrivateKey = YOUR_PRIVATE_KEY\n\ + Address = {}\n\ + {}\n\ + \n\ + [Peer]\n\ + PublicKey = {}\n\ + AllowedIPs = {}\n\ + Endpoint = {}:{}\n\ + PersistentKeepalive = 300", + self.wireguard_ip, dns, network.pubkey, allowed_ips, network.endpoint, network.port, + ) + } + + pub async fn find_by_ip(pool: &DbPool, ip: &str) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created \ + FROM device WHERE wireguard_ip = $1", + ip + ) + .fetch_optional(pool) + .await + } + + pub async fn find_by_pubkey(pool: &DbPool, pubkey: &str) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created \ + FROM device WHERE wireguard_pubkey = $1", + pubkey + ) + .fetch_optional(pool) + .await + } + + pub async fn find_by_id_and_username( + pool: &DbPool, + id: i64, + username: &str, + ) -> Result, SqlxError> { + query_as!( + Self, + "SELECT device.id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created \ + FROM device JOIN \"user\" ON device.user_id = \"user\".id \ + WHERE device.id = $1 AND \"user\".username = $2", + id, + username + ) + .fetch_optional(pool) + .await + } + + pub async fn find_by_id_and_user_id( + pool: &DbPool, + id: i64, + user_id: i64, + ) -> Result, SqlxError> { + query_as!( + Self, + "SELECT device.id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created \ + FROM device JOIN \"user\" ON device.user_id = \"user\".id \ + WHERE device.id = $1 AND \"user\".id = $2", + id, + user_id + ) + .fetch_optional(pool) + .await + } + + pub async fn all_for_username(pool: &DbPool, username: &str) -> Result, SqlxError> { + query_as!( + Self, + "SELECT device.id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created \ + FROM device JOIN \"user\" ON device.user_id = \"user\".id \ + WHERE \"user\".username = $1", + username + ) + .fetch_all(pool) + .await + } + + pub async fn assign_device_ip( + pool: &DbPool, + user_id: i64, + name: String, + pubkey: String, + network: &WireguardNetwork, + ) -> Result { + let net_ip = network.address.ip(); + let net_network = network.address.network(); + let net_broadcast = network.address.broadcast(); + for ip in network.address.iter() { + if ip == net_ip || ip == net_network || ip == net_broadcast { + continue; + } + // Break loop if IP is unassigned and return device + match Self::find_by_ip(pool, &ip.to_string()).await? { + Some(_) => (), + None => { + info!("Created IP: {} for device: {}", ip, name); + let device = Self::new(name, ip.to_string(), pubkey, user_id); + return Ok(device); + } + } + } + Err(ModelError::CannotCreate) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[sqlx::test] + async fn test_assign_device_ip(pool: DbPool) { + let mut network = WireguardNetwork::default(); + network.try_set_address("10.1.1.1/30").unwrap(); + + let mut device = Device::assign_device_ip(&pool, 1, "dev1".into(), "key1".into(), &network) + .await + .unwrap(); + assert_eq!(device.wireguard_ip, "10.1.1.2"); + device.save(&pool).await.unwrap(); + + let device = + Device::assign_device_ip(&pool, 1, "dev4".into(), "key4".into(), &network).await; + assert!(device.is_err()); + } +} diff --git a/src/db/models/error.rs b/src/db/models/error.rs new file mode 100644 index 000000000..654459a35 --- /dev/null +++ b/src/db/models/error.rs @@ -0,0 +1,38 @@ +use std::{error, fmt}; + +#[derive(Debug)] +pub enum ModelError { + CannotCreate, + NetworkTooSmall, + SqlxError(sqlx::Error), + IpNetworkError(ipnetwork::IpNetworkError), +} + +impl error::Error for ModelError {} + +impl fmt::Display for ModelError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::CannotCreate => write!(f, "Cannot create model"), + Self::NetworkTooSmall => write!(f, "Network address will not fit existing devices"), + Self::SqlxError(error) => { + write!(f, "SqlxError {error}") + } + Self::IpNetworkError(error) => { + write!(f, "IpNetError {error}") + } + } + } +} + +impl From for ModelError { + fn from(err: sqlx::Error) -> ModelError { + ModelError::SqlxError(err) + } +} + +impl From for ModelError { + fn from(err: ipnetwork::IpNetworkError) -> ModelError { + ModelError::IpNetworkError(err) + } +} diff --git a/src/db/models/group.rs b/src/db/models/group.rs new file mode 100644 index 000000000..f6df0236e --- /dev/null +++ b/src/db/models/group.rs @@ -0,0 +1,93 @@ +use crate::DbPool; +use model_derive::Model; +use sqlx::{query_as, query_scalar, Error as SqlxError}; + +#[derive(Model)] +pub struct Group { + pub(crate) id: Option, + pub name: String, +} + +impl Group { + #[must_use] + pub fn new(name: &str) -> Self { + Self { + id: None, + name: name.into(), + } + } + + pub async fn find_by_name(pool: &DbPool, name: &str) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", name FROM \"group\" WHERE name = $1", + name + ) + .fetch_optional(pool) + .await + } + + pub async fn member_usernames(&self, pool: &DbPool) -> Result, SqlxError> { + if let Some(id) = self.id { + query_scalar!( + "SELECT \"user\".username FROM \"user\" JOIN group_user ON \"user\".id = group_user.user_id \ + WHERE group_user.group_id = $1", + id + ) + .fetch_all(pool) + .await + } else { + Ok(Vec::new()) + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::db::User; + + #[sqlx::test] + async fn test_group(pool: DbPool) { + let mut group = Group::new("worker"); + group.save(&pool).await.unwrap(); + + let fetched_group = Group::find_by_name(&pool, "worker").await.unwrap(); + assert!(fetched_group.is_some()); + assert_eq!(fetched_group.unwrap().name, "worker"); + + let fetched_group = Group::find_by_name(&pool, "wheel").await.unwrap(); + assert!(fetched_group.is_none()); + + group.delete(&pool).await.unwrap(); + + let fetched_group = Group::find_by_name(&pool, "worker").await.unwrap(); + assert!(fetched_group.is_none()); + } + + #[sqlx::test] + async fn test_group_members(pool: DbPool) { + let mut group = Group::new("worker"); + group.save(&pool).await.unwrap(); + + let mut user = User::new( + "hpotter".into(), + "pass123", + "Potter".into(), + "Harry".into(), + "h.potter@hogwart.edu.uk".into(), + None, + ); + user.save(&pool).await.unwrap(); + user.add_to_group(&pool, &group).await.unwrap(); + + let members = group.member_usernames(&pool).await.unwrap(); + assert_eq!(members.len(), 1); + assert_eq!(members[0], user.username); + + user.remove_from_group(&pool, &group).await.unwrap(); + + let members = group.member_usernames(&pool).await.unwrap(); + assert!(members.is_empty()); + } +} diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs new file mode 100644 index 000000000..6f2773ecf --- /dev/null +++ b/src/db/models/mod.rs @@ -0,0 +1,93 @@ +pub mod device; +pub mod error; +pub mod group; +pub mod session; +pub mod settings; +pub mod user; +pub mod wallet; +pub mod webauthn; +pub mod webhook; +pub mod wireguard; + +use super::DbPool; +use crate::enterprise::db::openid::AuthorizedApp; +use device::Device; +use sqlx::Error as SqlxError; +use user::{MFAMethod, User}; + +#[derive(Deserialize, Serialize)] +pub struct WalletInfo { + pub address: String, + pub name: String, + pub chain_id: i64, + pub use_for_mfa: bool, +} + +/// Only `id` and `name` from [`WebAuthn`]. +#[derive(Deserialize, Serialize)] +pub struct SecurityKey { + pub id: i64, + pub name: String, +} + +// FIXME: [`UserInfo`] does not belong here. +#[derive(Deserialize, Serialize)] +pub struct UserInfo { + pub username: String, + pub last_name: String, + pub first_name: String, + pub email: String, + pub phone: Option, + pub ssh_key: Option, + pub pgp_key: Option, + pub pgp_cert_id: Option, + #[serde(default)] + pub groups: Vec, + #[serde(default)] + pub devices: Vec, + #[serde(default)] + pub authorized_apps: Vec, + #[serde(default)] + pub wallets: Vec, + #[serde(default)] + pub security_keys: Vec, + pub mfa_method: MFAMethod, +} + +impl UserInfo { + pub async fn from_user(pool: &DbPool, user: User) -> Result { + let groups = user.member_of(pool).await?; + let devices = user.devices(pool).await?; + let authorized_apps = AuthorizedApp::all_for_user(pool, user.id.unwrap()).await?; + let wallets = user.wallets(pool).await?; + let security_keys = user.security_keys(pool).await?; + Ok(Self { + username: user.username, + last_name: user.last_name, + first_name: user.first_name, + email: user.email, + phone: user.phone, + ssh_key: user.ssh_key, + pgp_key: user.pgp_key, + pgp_cert_id: user.pgp_cert_id, + groups, + devices, + authorized_apps, + wallets, + security_keys, + mfa_method: user.mfa_method, + }) + } + + pub fn into_user(self, user: &mut User) { + user.username = self.username; + user.last_name = self.last_name; + user.first_name = self.first_name; + user.email = self.email; + user.phone = self.phone; + user.ssh_key = self.ssh_key; + user.pgp_key = self.pgp_key; + user.pgp_cert_id = self.pgp_cert_id; + user.mfa_method = self.mfa_method; + } +} diff --git a/src/db/models/session.rs b/src/db/models/session.rs new file mode 100644 index 000000000..49407e521 --- /dev/null +++ b/src/db/models/session.rs @@ -0,0 +1,164 @@ +use crate::{auth::SESSION_TIMEOUT, db::DbPool}; +use chrono::{Duration, NaiveDateTime, Utc}; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use sqlx::{query, query_as, Error as SqlxError, Type}; +use webauthn_rs::prelude::{PasskeyAuthentication, PasskeyRegistration}; + +#[derive(Clone, PartialEq, Type)] +#[repr(i16)] +pub enum SessionState { + NotVerified, + PasswordVerified, + MultiFactorVerified, +} + +#[derive(Clone)] +pub struct Session { + pub id: String, + pub user_id: i64, + pub state: SessionState, + pub created: NaiveDateTime, + pub expires: NaiveDateTime, + pub webauthn_challenge: Option>, + pub web3_challenge: Option, +} + +impl Session { + #[must_use] + pub fn new(user_id: i64, state: SessionState) -> Self { + let now = Utc::now(); + Self { + id: thread_rng() + .sample_iter(Alphanumeric) + .take(24) + .map(char::from) + .collect(), + user_id, + state, + created: now.naive_utc(), + expires: (now + Duration::seconds(SESSION_TIMEOUT as i64)).naive_utc(), + webauthn_challenge: None, + web3_challenge: None, + } + } + + pub fn expired(&self) -> bool { + self.expires < Utc::now().naive_utc() + } + + pub async fn find_by_id(pool: &DbPool, id: &str) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id, user_id, state \"state: SessionState\", created, expires, webauthn_challenge, \ + web3_challenge FROM session WHERE id = $1", + id + ) + .fetch_optional(pool) + .await + } + + pub async fn save(&self, pool: &DbPool) -> Result<(), SqlxError> { + query!( + "INSERT INTO session (id, user_id, state, created, expires, webauthn_challenge, web3_challenge) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", + self.id, + self.user_id, + self.state as i16, + self.created, + self.expires, + self.webauthn_challenge, + self.web3_challenge, + ) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn set_state(&mut self, pool: &DbPool, state: SessionState) -> Result<(), SqlxError> { + query!( + "UPDATE session SET state = $1 WHERE id = $2", + state as i16, + self.id + ) + .execute(pool) + .await?; + self.state = state; + Ok(()) + } + + pub fn get_passkey_registration(&self) -> Option { + self.webauthn_challenge + .as_ref() + .and_then(|challenge| serde_cbor::from_slice(challenge).ok()) + } + + pub fn get_passkey_authentication(&self) -> Option { + self.webauthn_challenge + .as_ref() + .and_then(|challenge| serde_cbor::from_slice(challenge).ok()) + } + + pub async fn set_passkey_authentication( + &mut self, + pool: &DbPool, + passkey_auth: &PasskeyAuthentication, + ) -> Result<(), SqlxError> { + let webauthn_challenge = serde_cbor::to_vec(passkey_auth).unwrap(); + query!( + "UPDATE session SET webauthn_challenge = $1 WHERE id = $2", + webauthn_challenge, + self.id + ) + .execute(pool) + .await?; + self.webauthn_challenge = Some(webauthn_challenge); + Ok(()) + } + + pub async fn set_passkey_registration( + &mut self, + pool: &DbPool, + passkey_reg: &PasskeyRegistration, + ) -> Result<(), SqlxError> { + let webauthn_challenge = serde_cbor::to_vec(passkey_reg).unwrap(); + query!( + "UPDATE session SET webauthn_challenge = $1 WHERE id = $2", + webauthn_challenge, + self.id + ) + .execute(pool) + .await?; + self.webauthn_challenge = Some(webauthn_challenge); + Ok(()) + } + + pub async fn set_web3_challenge( + &mut self, + pool: &DbPool, + web3_challenge: String, + ) -> Result<(), SqlxError> { + query!( + "UPDATE session SET web3_challenge = $1 WHERE id = $2", + web3_challenge, + self.id + ) + .execute(pool) + .await?; + self.web3_challenge = Some(web3_challenge); + Ok(()) + } + + pub async fn delete(self, pool: &DbPool) -> Result<(), SqlxError> { + query!("DELETE FROM session WHERE id = $1", self.id) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn delete_expired(pool: &DbPool) -> Result<(), SqlxError> { + query!("DELETE FROM session WHERE expires < now()",) + .execute(pool) + .await?; + Ok(()) + } +} diff --git a/src/db/models/settings.rs b/src/db/models/settings.rs new file mode 100644 index 000000000..08d575823 --- /dev/null +++ b/src/db/models/settings.rs @@ -0,0 +1,16 @@ +use crate::DbPool; +use model_derive::Model; + +#[derive(Model, Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct Settings { + #[serde(skip)] + pub id: Option, + pub web3_enabled: bool, + pub openid_enabled: bool, + pub oauth_enabled: bool, + pub ldap_enabled: bool, + pub wireguard_enabled: bool, + pub webhooks_enabled: bool, + pub worker_enabled: bool, + pub challenge_template: String, +} diff --git a/src/db/models/user.rs b/src/db/models/user.rs new file mode 100644 index 000000000..13ec95cb1 --- /dev/null +++ b/src/db/models/user.rs @@ -0,0 +1,411 @@ +use super::{device::Device, group::Group, SecurityKey, WalletInfo}; +use crate::{auth::TOTP_CODE_VALIDITY_PERIOD, DbPool}; +use argon2::{ + password_hash::{ + errors::Error as HashError, rand_core::OsRng, PasswordHash, PasswordHasher, + PasswordVerifier, SaltString, + }, + Argon2, +}; +use model_derive::Model; +use otpauth::TOTP; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use sqlx::{query, query_as, query_scalar, Error as SqlxError, Type}; +use std::time::SystemTime; + +const RECOVERY_CODES_COUNT: usize = 8; + +#[derive(Deserialize, Serialize, Type)] +#[sqlx(type_name = "mfa_method", rename_all = "snake_case")] +pub enum MFAMethod { + None, + OneTimePassword, + WebAuthn, + Web3, +} + +#[derive(Model)] +pub struct User { + pub id: Option, + pub username: String, + password_hash: String, + pub last_name: String, + pub first_name: String, + pub email: String, + pub phone: Option, + pub ssh_key: Option, + pub pgp_key: Option, + pub pgp_cert_id: Option, + pub mfa_enabled: bool, + // secret has been verified and TOTP can be used + pub totp_enabled: bool, + totp_secret: Option>, + #[model(enum)] + pub mfa_method: MFAMethod, + #[model(ref)] + recovery_codes: Vec, +} + +impl User { + fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default() + .hash_password(password.as_bytes(), &salt)? + .to_string()) + } + + #[must_use] + pub fn new( + username: String, + password: &str, + last_name: String, + first_name: String, + email: String, + phone: Option, + ) -> Self { + Self { + id: None, + username, + password_hash: Self::hash_password(password).unwrap(), + last_name, + first_name, + email, + phone, + ssh_key: None, + pgp_key: None, + pgp_cert_id: None, + mfa_enabled: false, + totp_enabled: false, + totp_secret: None, + mfa_method: MFAMethod::None, + recovery_codes: Vec::new(), + } + } + + pub fn set_password(&mut self, password: &str) { + self.password_hash = Self::hash_password(password).unwrap(); + } + + pub fn verify_password(&self, password: &str) -> Result<(), HashError> { + let parsed_hash = PasswordHash::new(&self.password_hash)?; + Argon2::default().verify_password(password.as_bytes(), &parsed_hash) + } + + /// Generate new `secret`, save it, then return it as RFC 4648 base32-encoded string. + pub async fn new_secret(&mut self, pool: &DbPool) -> Result { + let secret = thread_rng().gen::<[u8; 20]>().to_vec(); + if let Some(id) = self.id { + query!( + "UPDATE \"user\" SET totp_secret = $1 WHERE id = $2", + secret, + id + ) + .execute(pool) + .await?; + } + let secret_base32 = TOTP::from_bytes(&secret).base32_secret(); + self.totp_secret = Some(secret); + Ok(secret_base32) + } + + /// Enable MFA; generate new recovery codes. + pub async fn enable_mfa(&mut self, pool: &DbPool) -> Result<(), SqlxError> { + self.recovery_codes.clear(); + for _ in 0..RECOVERY_CODES_COUNT { + let code = thread_rng() + .sample_iter(Alphanumeric) + .take(16) + .map(char::from) + .collect(); + self.recovery_codes.push(code); + } + if let Some(id) = self.id { + query!( + "UPDATE \"user\" SET mfa_enabled = TRUE, recovery_codes = $2 WHERE id = $1", + id, + &self.recovery_codes + ) + .execute(pool) + .await?; + } + self.mfa_enabled = true; + Ok(()) + } + + /// Disable MFA; discard recovery codes. + pub async fn disable_mfa(&mut self, pool: &DbPool) -> Result<(), SqlxError> { + if let Some(id) = self.id { + query!( + "UPDATE \"user\" SET mfa_enabled = FALSE AND recovery_codes = '{}' WHERE id = $1", + id + ) + .execute(pool) + .await?; + } + self.mfa_enabled = false; + self.recovery_codes.clear(); + Ok(()) + } + + /// Enable TOTP + pub async fn enable_totp(&mut self, pool: &DbPool) -> Result<(), SqlxError> { + if !self.totp_enabled { + if let Some(id) = self.id { + query!("UPDATE \"user\" SET totp_enabled = TRUE WHERE id = $1", id) + .execute(pool) + .await?; + } + self.totp_enabled = false; + } + Ok(()) + } + + /// Disable TOTP; discard the secret. + pub async fn disable_totp(&mut self, pool: &DbPool) -> Result<(), SqlxError> { + if self.totp_enabled { + if let Some(id) = self.id { + query!( + "UPDATE \"user\" SET totp_enabled = FALSE AND totp_secret = NULL WHERE id = $1", + id + ) + .execute(pool) + .await?; + } + self.totp_enabled = false; + self.totp_secret = None; + } + Ok(()) + } + + /// Check if TOTP `code` is valid. + pub fn verify_code(&self, code: u32) -> bool { + if let Some(totp_secret) = &self.totp_secret { + let totp = TOTP::from_bytes(totp_secret); + if let Ok(timestamp) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { + return totp.verify(code, TOTP_CODE_VALIDITY_PERIOD, timestamp.as_secs()); + } + } + false + } + + /// Verify recovery code. If it is valid, consume it, so it can't be used again. + pub async fn verify_recovery_code( + &mut self, + pool: &DbPool, + code: &str, + ) -> Result { + if let Some(index) = self.recovery_codes.iter().position(|c| c == code) { + // Note: swap_remove() should be faster than remove(). + self.recovery_codes.swap_remove(index); + if let Some(id) = self.id { + query!( + "UPDATE \"user\" SET recovery_codes = $2 WHERE id = $1", + id, + &self.recovery_codes + ) + .execute(pool) + .await?; + } + Ok(true) + } else { + Ok(false) + } + } + + pub async fn find_by_username( + pool: &DbPool, + username: &str, + ) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", username, password_hash, last_name, first_name, email, \ + phone, ssh_key, pgp_key, pgp_cert_id, mfa_enabled, totp_enabled, totp_secret, \ + mfa_method \"mfa_method: _\", recovery_codes \ + FROM \"user\" WHERE username = $1", + username + ) + .fetch_optional(pool) + .await + } + + pub async fn member_of(&self, pool: &DbPool) -> Result, SqlxError> { + if let Some(id) = self.id { + query_scalar!( + "SELECT \"group\".name FROM \"group\" JOIN group_user ON \"group\".id = group_user.group_id \ + WHERE group_user.user_id = $1", + id + ) + .fetch_all(pool) + .await + } else { + Ok(Vec::new()) + } + } + + pub async fn devices(&self, pool: &DbPool) -> Result, SqlxError> { + if let Some(id) = self.id { + query_as!( + Device, + "SELECT device.id \"id?\", name, wireguard_ip, wireguard_pubkey, user_id, created \ + FROM device WHERE user_id = $1", + id + ) + .fetch_all(pool) + .await + } else { + Ok(Vec::new()) + } + } + + pub async fn wallets(&self, pool: &DbPool) -> Result, SqlxError> { + if let Some(id) = self.id { + query_as!( + WalletInfo, + "SELECT address \"address!\", name, chain_id, use_for_mfa \ + FROM wallet WHERE user_id = $1 AND validation_timestamp IS NOT NULL", + id + ) + .fetch_all(pool) + .await + } else { + Ok(Vec::new()) + } + } + + pub async fn security_keys(&self, pool: &DbPool) -> Result, SqlxError> { + if let Some(id) = self.id { + query_as!( + SecurityKey, + "SELECT id \"id!\", name FROM webauthn WHERE user_id = $1", + id + ) + .fetch_all(pool) + .await + } else { + Ok(Vec::new()) + } + } + + pub async fn add_to_group(&self, pool: &DbPool, group: &Group) -> Result<(), SqlxError> { + if let (Some(id), Some(group_id)) = (self.id, group.id) { + query!( + "INSERT INTO group_user (group_id, user_id) VALUES ($1, $2) \ + ON CONFLICT DO NOTHING", + group_id, + id + ) + .execute(pool) + .await?; + } + Ok(()) + } + + pub async fn remove_from_group(&self, pool: &DbPool, group: &Group) -> Result<(), SqlxError> { + if let (Some(id), Some(group_id)) = (self.id, group.id) { + query!( + "DELETE FROM group_user WHERE group_id = $1 AND user_id = $2", + group_id, + id + ) + .execute(pool) + .await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[sqlx::test] + async fn test_user(pool: DbPool) { + let mut user = User::new( + "hpotter".into(), + "pass123", + "Potter".into(), + "Harry".into(), + "h.potter@hogwart.edu.uk".into(), + None, + ); + user.save(&pool).await.unwrap(); + + let fetched_user = User::find_by_username(&pool, "hpotter").await.unwrap(); + assert!(fetched_user.is_some()); + assert_eq!(fetched_user.unwrap().email, "h.potter@hogwart.edu.uk"); + + user.email = "harry.potter@hogwart.edu.uk".into(); + user.save(&pool).await.unwrap(); + + let fetched_user = User::find_by_username(&pool, "hpotter").await.unwrap(); + assert!(fetched_user.is_some()); + assert_eq!(fetched_user.unwrap().email, "harry.potter@hogwart.edu.uk"); + + assert!(user.verify_password("pass123").is_ok()); + + let fetched_user = User::find_by_username(&pool, "rweasley").await.unwrap(); + assert!(fetched_user.is_none()); + } + + #[sqlx::test] + async fn test_all_users(pool: DbPool) { + let mut harry = User::new( + "hpotter".into(), + "pass123", + "Potter".into(), + "Harry".into(), + "h.potter@hogwart.edu.uk".into(), + None, + ); + harry.save(&pool).await.unwrap(); + + let mut albus = User::new( + "adumbledore".into(), + "magic!", + "Dumbledore".into(), + "Albus".into(), + "a.dumbledore@hogwart.edu.uk".into(), + None, + ); + albus.save(&pool).await.unwrap(); + + let users = User::all(&pool).await.unwrap(); + // Including "admin" user from migrations. + assert_eq!(users.len(), 3); + + albus.delete(&pool).await.unwrap(); + + let users = User::all(&pool).await.unwrap(); + assert_eq!(users.len(), 2); + } + + #[sqlx::test] + async fn test_recovery_codes(pool: DbPool) { + let mut harry = User::new( + "hpotter".into(), + "pass123", + "Potter".into(), + "Harry".into(), + "h.potter@hogwart.edu.uk".into(), + None, + ); + harry.enable_mfa(&pool).await.unwrap(); + assert_eq!(harry.recovery_codes.len(), RECOVERY_CODES_COUNT); + harry.save(&pool).await.unwrap(); + + let fetched_user = User::find_by_username(&pool, "hpotter").await.unwrap(); + assert!(fetched_user.is_some()); + + let mut user = fetched_user.unwrap(); + assert_eq!(user.recovery_codes.len(), RECOVERY_CODES_COUNT); + assert!(!user + .verify_recovery_code(&pool, "invalid code") + .await + .unwrap()); + let codes = user.recovery_codes.clone(); + for code in &codes { + assert!(user.verify_recovery_code(&pool, code).await.unwrap()); + } + assert_eq!(user.recovery_codes.len(), 0); + } +} diff --git a/src/db/models/wallet.rs b/src/db/models/wallet.rs new file mode 100644 index 000000000..9174505cc --- /dev/null +++ b/src/db/models/wallet.rs @@ -0,0 +1,181 @@ +use crate::{hex::hex_decode, DbPool}; +use chrono::{NaiveDateTime, Utc}; +use model_derive::Model; +use secp256k1::{ + ecdsa::{RecoverableSignature, RecoveryId}, + Message, Secp256k1, +}; +use sqlx::{query, query_as, Error as SqlxError}; +use std::{ + error::Error, + fmt::{Display, Formatter, Result as FmtResult}, +}; +use tiny_keccak::{Hasher, Keccak}; + +#[derive(Debug)] +pub enum Web3Error { + Decode, + InvalidMessage, + InvalidRecoveryId, + ParseSignature, + Recovery, + VerifyAddress, +} + +impl Error for Web3Error {} + +impl Display for Web3Error { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + match self { + Self::Decode => write!(f, "hex decoding error"), + Self::InvalidMessage => write!(f, "invalid message"), + Self::InvalidRecoveryId => write!(f, "invalid recovery id"), + Self::ParseSignature => write!(f, "error parsing signature"), + Self::Recovery => write!(f, "recovery error"), + Self::VerifyAddress => write!(f, "error veryfing address"), + } + } +} + +/// Compute the Keccak-256 hash of input bytes. +fn keccak256(bytes: &[u8]) -> [u8; 32] { + let mut output = [0u8; 32]; + let mut hasher = Keccak::v256(); + hasher.update(bytes); + hasher.finalize(&mut output); + output +} + +fn hash_message>(message: S) -> [u8; 32] { + let message = message.as_ref(); + let mut eth_message = format!("\x19Ethereum Signed Message:\n{}", message.len()).into_bytes(); + eth_message.extend_from_slice(message); + keccak256(ð_message) +} + +#[derive(Model)] +pub struct Wallet { + pub(crate) id: Option, + pub(crate) user_id: i64, + pub address: String, + pub name: String, + pub chain_id: i64, + pub challenge_message: String, + pub challenge_signature: Option, + pub creation_timestamp: NaiveDateTime, + pub validation_timestamp: Option, + pub use_for_mfa: bool, +} + +impl Wallet { + #[must_use] + pub fn new_for_user( + user_id: i64, + address: String, + name: String, + chain_id: i64, + challenge_message: String, + ) -> Self { + Self { + id: None, + user_id, + address, + name, + chain_id, + challenge_message, + challenge_signature: None, + creation_timestamp: Utc::now().naive_utc(), + validation_timestamp: None, + use_for_mfa: false, + } + } + + pub fn verify_address(&self, message: &str, signature: &str) -> Result { + let address_array = hex_decode(&self.address).map_err(|_| Web3Error::Decode)?; + let signature_array = hex_decode(signature).map_err(|_| Web3Error::Decode)?; + + let hash_msg = hash_message(message); + let message = Message::from_slice(&hash_msg).map_err(|_| Web3Error::InvalidMessage)?; + let id = match signature_array[64] { + 0 | 27 => 0, + 1 | 28 => 1, + v if v >= 35 => i32::from((v - 1) & 1), + _ => return Err(Web3Error::InvalidRecoveryId), + }; + let recovery_id = RecoveryId::from_i32(id).map_err(|_| Web3Error::ParseSignature)?; + let recoverable_signature = + RecoverableSignature::from_compact(&signature_array[0..64], recovery_id) + .map_err(|_| Web3Error::ParseSignature)?; + let public_key = Secp256k1::new() + .recover_ecdsa(&message, &recoverable_signature) + .map_err(|_| Web3Error::Recovery)?; + let public_key = public_key.serialize_uncompressed(); + let hash = keccak256(&public_key[1..]); + + Ok(hash[12..] == address_array) + } + + pub fn validate_signature(&self, signature: &str) -> Result<(), Web3Error> { + if self.verify_address(&self.challenge_message, signature)? { + Ok(()) + } else { + Err(Web3Error::VerifyAddress) + } + } + + pub async fn set_signature(&mut self, pool: &DbPool, signature: &str) -> Result<(), SqlxError> { + self.challenge_signature = Some(signature.into()); + self.validation_timestamp = Some(Utc::now().naive_utc()); + if let Some(id) = self.id { + query!( + "UPDATE wallet SET challenge_signature = $1, validation_timestamp = $2 WHERE id = $3", + self.challenge_signature, self.validation_timestamp, id + ) + .execute(pool) + .await?; + } + Ok(()) + } + + pub async fn find_by_user_and_address( + pool: &DbPool, + user_id: i64, + address: &str, + ) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", user_id, address, name, chain_id, challenge_message, challenge_signature, \ + creation_timestamp, validation_timestamp, use_for_mfa FROM wallet \ + WHERE user_id = $1 AND address = $2", + user_id, + address + ) + .fetch_optional(pool) + .await + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_verify_address() { + for (address, signature) in [ + ("0x4aF8803CBAD86BA65ED347a3fbB3fb50e96eDD3e", + "0xcf9a650ed3dbb594f68a0614fc385363f17a150f0ced6e0e92f6cc40923ec0d86c70aa3a74e73216a57d6ae6a1e07e5951416491a2660a88d5d78a5ec7e4a9bd1c"), + ("0x8B9B066ebe684Efcf0Cf882392A1225744a1E5a5", + "0x4288f0a78b55bd3d731f4ffab3504bf6a1fe1c01aeb8f4ec21cb4d3db1459592524d595ab3b745001e8b4626e5d4741facbbf0b7ade41076664287ba7bd8d1c600"), + ("0xd3Fce6f0794901b5d43A92935693F7c1A364Da29", + "0xad419ec9ac28625a246a7a70c5a28f7057a54265cfae427d977deb6196bcfac26e847b4a6f942b793b19f2a6803bc49019e4867fafff8830d7270db48dddd21a01"), + ] { + let wallet = Wallet::new_for_user(0, address.into(), String::new(), 0, String::new()); + let result = wallet.verify_address( + "By signing this message you confirm that you're the owner of the wallet", + signature, + ) + .unwrap(); + assert!(result); + } + } +} diff --git a/src/db/models/webauthn.rs b/src/db/models/webauthn.rs new file mode 100644 index 000000000..061e6915e --- /dev/null +++ b/src/db/models/webauthn.rs @@ -0,0 +1,53 @@ +use super::{error::ModelError, DbPool}; +use model_derive::Model; +use sqlx::{query_as, query_scalar, Error as SqlxError}; +use webauthn_rs::prelude::Passkey; + +#[derive(Model)] +pub struct WebAuthn { + id: Option, + pub(crate) user_id: i64, + name: String, + // serialize from/to [`Passkey`] + pub passkey: Vec, +} + +impl WebAuthn { + pub fn new(user_id: i64, name: String, passkey: &Passkey) -> Result { + let passkey = serde_cbor::to_vec(passkey).map_err(|_| ModelError::CannotCreate)?; + Ok(Self { + id: None, + user_id, + name, + passkey, + }) + } + + pub(crate) fn passkey(&self) -> Result { + let passkey = + serde_cbor::from_slice(&self.passkey).map_err(|_| ModelError::CannotCreate)?; + Ok(passkey) + } + + pub async fn passkeys_for_user(pool: &DbPool, user_id: i64) -> Result, SqlxError> { + query_scalar!("SELECT passkey FROM webauthn WHERE user_id = $1", user_id) + .fetch_all(pool) + .await + .map(|bin_keys| { + bin_keys + .iter() + .map(|bin| serde_cbor::from_slice(bin).expect("Can't deserialize Passkey")) + .collect() + }) + } + + pub async fn all_for_user(pool: &DbPool, user_id: i64) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", user_id, name, passkey FROM webauthn WHERE user_id = $1", + user_id + ) + .fetch_all(pool) + .await + } +} diff --git a/src/db/models/webhook.rs b/src/db/models/webhook.rs new file mode 100644 index 000000000..5f75a5a88 --- /dev/null +++ b/src/db/models/webhook.rs @@ -0,0 +1,82 @@ +use super::UserInfo; +use crate::DbPool; +use model_derive::Model; +use sqlx::{query_as, Error as SqlxError, FromRow}; + +pub enum AppEvent { + UserCreated(UserInfo), + UserModified(UserInfo), + UserDeleted(String), + HWKeyProvision(HWKeyUserData), +} + +#[derive(Serialize)] +pub struct HWKeyUserData { + pub username: String, + pub email: String, + pub ssh_key: String, + pub pgp_key: String, + pub pgp_cert_id: String, +} + +impl AppEvent { + // Debug name + pub fn name(&self) -> &str { + match self { + Self::UserCreated(_) => "user created", + Self::UserModified(_) => "user modified", + Self::UserDeleted(_) => "user deleted", + Self::HWKeyProvision(_) => "hwkey provisioned", + } + } + + /// Database column name. + pub fn column_name(&self) -> &str { + match self { + Self::UserCreated(_) => "on_user_created", + Self::UserModified(_) => "on_user_modified", + Self::UserDeleted(_) => "on_user_deleted", + Self::HWKeyProvision(_) => "on_hwkey_provision", + } + } +} + +#[derive(Deserialize, Model, Serialize, FromRow)] +pub struct WebHook { + #[serde(default)] + pub id: Option, + pub url: String, + pub description: String, + pub token: String, + pub enabled: bool, + pub on_user_created: bool, + pub on_user_deleted: bool, + pub on_user_modified: bool, + pub on_hwkey_provision: bool, +} + +impl WebHook { + // Fetch all enabled webhooks. + pub async fn all_enabled(pool: &DbPool, trigger: &AppEvent) -> Result, SqlxError> { + let column_name = trigger.column_name(); + let query = format!( + "SELECT id \"id?\", url, description, token, enabled, on_user_created, \ + on_user_deleted, on_user_modified, on_hwkey_provision FROM webhook \ + WHERE enabled AND {}", + column_name, + ); + query_as(&query).fetch_all(pool).await + } + + /// Find [`WebHook`] by URL. + pub async fn find_by_url(pool: &DbPool, url: &str) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", url, description, token, enabled, on_user_created, \ + on_user_deleted, on_user_modified, on_hwkey_provision FROM webhook WHERE url = $1", + url + ) + .fetch_optional(pool) + .await + } +} diff --git a/src/db/models/wireguard.rs b/src/db/models/wireguard.rs new file mode 100644 index 000000000..66e6f088f --- /dev/null +++ b/src/db/models/wireguard.rs @@ -0,0 +1,634 @@ +use super::{device::Device, error::ModelError, DbPool, User, UserInfo}; +use base64; +use chrono::{Duration, NaiveDateTime, Utc}; +use ipnetwork::{IpNetwork, IpNetworkError, NetworkSize}; +use model_derive::Model; +use rand_core::OsRng; +use sqlx::{query_as, query_scalar, Error as SqlxError, FromRow}; +use std::{ + collections::HashMap, + fmt::Debug, + net::{IpAddr, Ipv4Addr}, + str::FromStr, +}; +use x25519_dalek::{PublicKey, StaticSecret}; + +pub static WIREGUARD_MAX_HANDSHAKE_MINUTES: u32 = 5; +pub static PEER_STATS_LIMIT: i64 = 6 * 60; + +/// Defines datetime aggregation levels +pub enum DateTimeAggregation { + Hour, + Minute, +} + +impl DateTimeAggregation { + /// Returns database format string for given aggregation variant + fn fstring(&self) -> &str { + match self { + Self::Hour => "hour", + Self::Minute => "minute", + } + } +} + +#[derive(Debug)] +pub enum GatewayEvent { + NetworkCreated(WireguardNetwork), + NetworkModified(WireguardNetwork), + NetworkDeleted(String), + DeviceCreated(Device), + DeviceModified(Device), + DeviceDeleted(String), +} + +/// Stores configuration required to setup a wireguard network +#[derive(Clone, Debug, Model, Deserialize, Serialize, PartialEq)] +#[table(wireguard_network)] +pub struct WireguardNetwork { + pub id: Option, + pub name: String, + #[model(enum)] + pub address: IpNetwork, + pub port: i32, + pub pubkey: String, + #[serde(default, skip_serializing)] + pub prvkey: String, + pub endpoint: String, + pub dns: Option, + #[model(ref)] + pub allowed_ips: Vec, + pub connected_at: Option, +} + +pub struct WireguardKey { + pub private: String, + pub public: String, +} + +impl WireguardNetwork { + pub fn new( + name: String, + address: IpNetwork, + port: i32, + endpoint: String, + dns: Option, + allowed_ips: Vec, + ) -> Result { + let prvkey = StaticSecret::new(&mut OsRng); + let pubkey = PublicKey::from(&prvkey); + Ok(Self { + id: None, + name, + address, + port, + pubkey: base64::encode(pubkey.to_bytes()), + prvkey: base64::encode(prvkey.to_bytes()), + endpoint, + dns, + allowed_ips, + connected_at: None, + }) + } + + /// Return number of devices that use this network. + async fn device_count(&self, pool: &DbPool) -> Result { + // FIXME: currently there is only one hard-coded network with id = 1. + query_scalar!("SELECT count(*) \"count!\" FROM device") + .fetch_one(pool) + .await + } + + /// Utility method to create wireguard keypair + #[must_use] + pub fn genkey() -> WireguardKey { + let private = StaticSecret::new(&mut OsRng); + let public = PublicKey::from(&private); + WireguardKey { + private: base64::encode(private.to_bytes()), + public: base64::encode(public.to_bytes()), + } + } + + /// Try to set `address` from `&str`. + pub fn try_set_address(&mut self, address: &str) -> Result { + IpNetwork::from_str(address).map(|network| { + self.address = network; + network + }) + } + + /// Try to change network address, changing device addresses if necessary. + pub async fn change_address( + &mut self, + pool: &DbPool, + new_address: IpNetwork, + ) -> Result<(), ModelError> { + let old_address = self.address; + + // check if new network size will fit all existing devices + let new_size = new_address.size(); + if new_size < old_address.size() { + // include address, network, and broadcast in the calculation + let count = self.device_count(pool).await? + 3; + match new_size { + NetworkSize::V4(size) => { + if count as u32 > size { + return Err(ModelError::NetworkTooSmall); + } + } + NetworkSize::V6(size) => { + if count as u128 > size { + return Err(ModelError::NetworkTooSmall); + } + } + } + } + + // re-address all devices + if new_address.network() != old_address.network() { + let transaction = pool.begin().await?; + + let mut devices = Device::all(pool).await?; + let net_ip = new_address.ip(); + let net_network = new_address.network(); + let net_broadcast = new_address.broadcast(); + let mut devices_iter = devices.iter_mut(); + for ip in new_address.iter() { + if ip == net_ip || ip == net_network || ip == net_broadcast { + continue; + } + match devices_iter.next() { + Some(device) => { + device.wireguard_ip = ip.to_string(); + device.save(pool).await?; + } + None => break, + } + } + + transaction.commit().await?; + } + + self.address = new_address; + Ok(()) + } + + async fn fetch_latest_stats( + conn: &DbPool, + device_id: i64, + ) -> Result, SqlxError> { + let stats = query_as!( + WireguardPeerStats, + r#" + SELECT id "id?", device_id "device_id!", collected_at "collected_at!", network "network!", + endpoint, upload "upload!", download "download!", latest_handshake "latest_handshake!", allowed_ips + FROM wireguard_peer_stats + WHERE device_id = $1 + ORDER BY collected_at DESC + LIMIT 1 + "#, + device_id + ) + .fetch_optional(conn) + .await?; + Ok(stats) + } + + /// Parse WireGuard IP address + fn parse_wireguard_ip(stats: &WireguardPeerStats) -> Option { + stats + .allowed_ips + .as_ref() + .and_then(|ips| Some(ips.split('/').next()?.to_owned())) + } + + /// Parse public IP address + fn parse_public_ip(stats: &WireguardPeerStats) -> Option { + stats + .endpoint + .as_ref() + .and_then(|ep| Some(ep.split(':').next()?.to_owned())) + } + + /// Finds when the device connected based on handshake timestamps + async fn connected_at( + conn: &DbPool, + device_id: i64, + ) -> Result, SqlxError> { + let connected_at = query_scalar!( + r#" + SELECT + latest_handshake "latest_handshake: NaiveDateTime" + FROM wireguard_peer_stats_view + WHERE device_id = $1 + AND latest_handshake IS NOT NULL + AND (latest_handshake_diff > $2 * interval '1 minute' OR latest_handshake_diff IS NULL) + ORDER BY collected_at DESC + LIMIT 1 + "#, + device_id, + WIREGUARD_MAX_HANDSHAKE_MINUTES as f64, + ) + .fetch_optional(conn) + .await?; + Ok(connected_at.flatten()) + } + + /// Retrieves stats for specified devices + async fn device_stats( + conn: &DbPool, + devices: &[Device], + from: &NaiveDateTime, + aggregation: &DateTimeAggregation, + ) -> Result, SqlxError> { + if devices.is_empty() { + return Ok(Vec::new()); + } + // query_as! macro doesn't work with `... WHERE ... IN (...) ` + // so we'll have to use format! macro + // https://github.com/launchbadge/sqlx/issues/875 + // https://github.com/launchbadge/sqlx/issues/656 + let device_ids = devices + .iter() + .filter_map(|d| d.id.map(|id| id.to_string())) + .collect::>() + .join(","); + let query = format!( + r#" + SELECT + device_id, + date_trunc($1, collected_at) as collected_at, + cast(sum(download) as bigint) as download, + cast(sum(upload) as bigint) as upload + FROM wireguard_peer_stats_view + WHERE device_id IN ({}) + AND collected_at >= $2 + GROUP BY 1, 2 + ORDER BY 1, 2 + "#, + device_ids + ); + let stats: Vec = query_as(&query) + .bind(aggregation.fstring()) + .bind(from) + .fetch_all(conn) + .await?; + let mut result = Vec::new(); + for device in devices { + let latest_stats = Self::fetch_latest_stats(conn, device.id.unwrap()).await?; + result.push(WireguardDeviceStatsRow { + id: device.id.unwrap(), + user_id: device.user_id, + name: device.name.clone(), + wireguard_ip: latest_stats.as_ref().and_then(Self::parse_wireguard_ip), + public_ip: latest_stats.as_ref().and_then(Self::parse_public_ip), + connected_at: Self::connected_at(conn, device.id.unwrap()).await?, + // Filter stats for this device + stats: stats + .iter() + .filter(|s| Some(s.device_id) == device.id) + .cloned() + .collect(), + }) + } + Ok(result) + } + + /// Retrieves network stats grouped by currently active users since `from` timestamp + pub async fn user_stats( + conn: &DbPool, + from: &NaiveDateTime, + aggregation: &DateTimeAggregation, + ) -> Result, SqlxError> { + let mut user_map: HashMap> = HashMap::new(); + let oldest_handshake = + (Utc::now() - Duration::minutes(WIREGUARD_MAX_HANDSHAKE_MINUTES.into())).naive_utc(); + // Retrieve connected devices from database + let devices = query_as!( + Device, + r#" + WITH s AS ( + SELECT DISTINCT ON (device_id) * + FROM wireguard_peer_stats + ORDER BY device_id, latest_handshake DESC + ) + SELECT + d.id "id?", d.name, d.wireguard_ip, d.wireguard_pubkey, d.user_id, d.created + FROM device d + JOIN s ON d.id = s.device_id + WHERE s.latest_handshake > $1 + "#, + oldest_handshake, + ) + .fetch_all(conn) + .await?; + // Retrieve data series for all active devices and assign them to users + let device_stats = Self::device_stats(conn, &devices, from, aggregation).await?; + for stats in device_stats { + user_map + .entry(stats.user_id) + .or_insert(Vec::new()) + .push(stats); + } + // Reshape final result + let mut stats = Vec::new(); + for u in user_map { + let user = User::find_by_id(conn, u.0) + .await? + .ok_or(SqlxError::RowNotFound)?; + stats.push(WireguardUserStatsRow { + user: UserInfo::from_user(conn, user).await?, + devices: u.1.clone(), + }); + } + Ok(stats) + } + + /// Retrieves total active users/devices since `from` timestamp + async fn total_activity( + conn: &DbPool, + from: &NaiveDateTime, + ) -> Result { + let activity_stats = query_as!( + WireguardNetworkActivityStats, + r#" + SELECT + COALESCE(COUNT(DISTINCT(u.id)), 0) as "active_users!", + COALESCE(COUNT(DISTINCT(s.device_id)), 0) as "active_devices!" + FROM "user" u + JOIN device d ON d.user_id = u.id + JOIN wireguard_peer_stats s ON s.device_id = d.id + WHERE latest_handshake >= $1 + "#, + from, + ) + .fetch_one(conn) + .await?; + Ok(activity_stats) + } + + /// Retrieves network upload & download time series since `from` timestamp + /// using `aggregation` (hour/minute) aggregation level + async fn transfer_series( + conn: &DbPool, + from: &NaiveDateTime, + aggregation: &DateTimeAggregation, + ) -> Result, SqlxError> { + let stats = query_as!( + WireguardStatsRow, + r#" + SELECT + date_trunc($1, collected_at) "collected_at: NaiveDateTime", + cast(sum(upload) AS bigint) upload, cast(sum(download) AS bigint) download + FROM wireguard_peer_stats_view + WHERE collected_at >= $2 + GROUP BY 1 + ORDER BY 1 + LIMIT $3 + "#, + aggregation.fstring(), + from, + PEER_STATS_LIMIT, + ) + .fetch_all(conn) + .await?; + Ok(stats) + } + + /// Retrieves network stats + pub async fn network_stats( + conn: &DbPool, + from: &NaiveDateTime, + aggregation: &DateTimeAggregation, + ) -> Result { + let activity = Self::total_activity(conn, from).await?; + let transfer_series = Self::transfer_series(conn, from, aggregation).await?; + Ok(WireguardNetworkStats { + active_users: activity.active_users, + active_devices: activity.active_devices, + upload: transfer_series.iter().filter_map(|t| t.upload).sum(), + download: transfer_series.iter().filter_map(|t| t.download).sum(), + transfer_series, + }) + } +} + +// [`IpNetwork`] does not implement [`Default`] +impl Default for WireguardNetwork { + fn default() -> Self { + Self { + id: Option::default(), + name: String::default(), + address: IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), + port: i32::default(), + pubkey: String::default(), + prvkey: String::default(), + endpoint: String::default(), + dns: Option::default(), + allowed_ips: Vec::default(), + connected_at: Option::default(), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct WireguardStatsRow { + pub collected_at: Option, + pub upload: Option, + pub download: Option, +} + +#[derive(FromRow, Serialize, Deserialize, Clone, Debug, PartialEq)] +pub struct WireguardDeviceTransferRow { + pub device_id: i64, + pub collected_at: Option, + pub upload: i64, + pub download: i64, +} + +#[derive(Serialize, Deserialize, Clone, Default)] +pub struct WireguardDeviceStatsRow { + pub id: i64, + pub stats: Vec, + pub user_id: i64, + pub name: String, + pub wireguard_ip: Option, + pub public_ip: Option, + pub connected_at: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct WireguardUserStatsRow { + pub user: UserInfo, + pub devices: Vec, +} + +#[derive(Model, Serialize, Deserialize, Debug)] +#[table(wireguard_peer_stats)] +pub struct WireguardPeerStats { + pub id: Option, + pub device_id: i64, + pub collected_at: NaiveDateTime, + pub network: i64, + pub endpoint: Option, + pub upload: i64, + pub download: i64, + pub latest_handshake: NaiveDateTime, + // FIXME: can contain multiple IP addresses + pub allowed_ips: Option, +} + +pub struct WireguardNetworkActivityStats { + pub active_users: i64, + pub active_devices: i64, +} + +pub struct WireguardNetworkTransferStats { + pub upload: i64, + pub download: i64, +} + +#[derive(Serialize, Deserialize)] +pub struct WireguardNetworkStats { + pub active_users: i64, + pub active_devices: i64, + pub upload: i64, + pub download: i64, + pub transfer_series: Vec, +} + +#[cfg(test)] +mod test { + use chrono::{Duration, SubsecRound}; + + use super::*; + + async fn add_devices(pool: &DbPool, network: &WireguardNetwork, count: usize) { + for i in 0..count { + let mut device = + Device::assign_device_ip(pool, 1, format!("dev{i}"), format!("key{i}"), network) + .await + .unwrap(); + device.save(pool).await.unwrap(); + } + } + + #[sqlx::test] + async fn test_change_address(pool: DbPool) { + let mut network = WireguardNetwork::default(); + network.try_set_address("10.1.1.1/29").unwrap(); + + add_devices(&pool, &network, 3).await; + + network + .change_address(&pool, "10.2.2.2/28".parse().unwrap()) + .await + .unwrap(); + + let dev0 = Device::find_by_pubkey(&pool, "key0") + .await + .unwrap() + .unwrap(); + assert_eq!(dev0.wireguard_ip, "10.2.2.1"); + + let dev1 = Device::find_by_pubkey(&pool, "key1") + .await + .unwrap() + .unwrap(); + assert_eq!(dev1.wireguard_ip, "10.2.2.3"); + + let dev2 = Device::find_by_pubkey(&pool, "key2") + .await + .unwrap() + .unwrap(); + assert_eq!(dev2.wireguard_ip, "10.2.2.4"); + } + + #[sqlx::test] + async fn test_change_address_wont_fit(pool: DbPool) { + let mut network = WireguardNetwork::default(); + network.try_set_address("10.1.1.1/29").unwrap(); + + add_devices(&pool, &network, 3).await; + + assert!(network + .change_address(&pool, "10.2.2.2/30".parse().unwrap()) + .await + .is_err()); + assert!(network + .change_address(&pool, "10.2.2.2/29".parse().unwrap()) + .await + .is_ok()); + } + + #[sqlx::test] + async fn test_connected_at_reconnection(pool: DbPool) { + let mut device = Device::new(String::new(), String::new(), String::new(), 1); + device.save(&pool).await.unwrap(); + + // insert stats + let samples = 60; // 1 hour of samples + let now = Utc::now().naive_utc(); + for i in 0..=samples { + // simulate connection 30 minutes ago + let handshake_minutes = i * if i < 31 { 1 } else { 10 }; + let mut wps = WireguardPeerStats { + id: None, + device_id: device.id.unwrap(), + collected_at: now - Duration::minutes(i), + network: 1, + endpoint: Some("11.22.33.44".into()), + upload: (samples - i) * 10, + download: (samples - i) * 20, + latest_handshake: now - Duration::minutes(handshake_minutes), + allowed_ips: Some("10.1.1.0/24".into()), + }; + wps.save(&pool).await.unwrap(); + } + + let connected_at = WireguardNetwork::connected_at(&pool, device.id.unwrap()) + .await + .unwrap() + .unwrap(); + assert_eq!( + connected_at, + // Postgres stores 6 sub-second digits while chrono stores 9 + (now - Duration::minutes(30)).trunc_subsecs(6), + ); + } + + #[sqlx::test] + async fn test_connected_at_always_connected(pool: DbPool) { + let mut device = Device::new(String::new(), String::new(), String::new(), 1); + device.save(&pool).await.unwrap(); + + // insert stats + let samples = 60; // 1 hour of samples + let now = Utc::now().naive_utc(); + for i in 0..=samples { + let mut wps = WireguardPeerStats { + id: None, + device_id: device.id.unwrap(), + collected_at: now - Duration::minutes(i), + network: 1, + endpoint: Some("11.22.33.44".into()), + upload: (samples - i) * 10, + download: (samples - i) * 20, + latest_handshake: now - Duration::minutes(i), // handshake every minute + allowed_ips: Some("10.1.1.0/24".into()), + }; + wps.save(&pool).await.unwrap(); + } + + let connected_at = WireguardNetwork::connected_at(&pool, device.id.unwrap()) + .await + .unwrap() + .unwrap(); + assert_eq!( + connected_at, + // Postgres stores 6 sub-second digits while chrono stores 9 + (now - Duration::minutes(samples)).trunc_subsecs(6), + ); + } +} diff --git a/src/enterprise/db/mod.rs b/src/enterprise/db/mod.rs new file mode 100644 index 000000000..50ac6f6ed --- /dev/null +++ b/src/enterprise/db/mod.rs @@ -0,0 +1 @@ +pub mod openid; diff --git a/src/enterprise/db/openid.rs b/src/enterprise/db/openid.rs new file mode 100644 index 000000000..64649432b --- /dev/null +++ b/src/enterprise/db/openid.rs @@ -0,0 +1,176 @@ +use crate::DbPool; +use model_derive::Model; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use sqlx::{query_as, Error as SqlxError}; + +#[derive(Deserialize, Model, Serialize)] +pub struct OpenIDClient { + pub id: Option, + pub name: String, + pub description: String, + pub home_url: String, + pub client_id: String, + pub client_secret: String, + pub redirect_uri: String, + pub enabled: bool, +} + +impl OpenIDClient { + /// Find client by `client_id`. + pub async fn find_enabled_for_client_id( + pool: &DbPool, + client_id: &str, + ) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", name, home_url, client_id, client_secret, redirect_uri, description, enabled \ + FROM openidclient WHERE client_id = $1 AND enabled", client_id) + .fetch_optional(pool) + .await + } +} + +#[derive(Deserialize, Serialize)] +pub struct NewOpenIDClient { + pub name: String, + pub description: String, + pub home_url: String, + pub redirect_uri: String, + pub enabled: bool, +} + +impl From for OpenIDClient { + fn from(new: NewOpenIDClient) -> Self { + let client_id = thread_rng() + .sample_iter(Alphanumeric) + .take(16) + .map(char::from) + .collect(); + let client_secret = thread_rng() + .sample_iter(Alphanumeric) + .take(32) + .map(char::from) + .collect(); + Self { + id: None, + name: new.name, + description: new.description, + home_url: new.home_url, + client_id, + client_secret, + redirect_uri: new.redirect_uri, + enabled: new.enabled, + } + } +} + +#[derive(Deserialize, Model, Serialize)] +#[table(openidclientauthcode)] +pub struct OpenIDClientAuth { + #[serde(default)] + id: Option, + /// User ID + pub user: String, + pub code: String, + pub client_id: String, + pub state: String, + pub scope: String, + pub redirect_uri: String, + pub nonce: Option, +} + +impl OpenIDClientAuth { + #[must_use] + pub fn new( + user: String, + code: String, + client_id: String, + state: String, + redirect_uri: String, + scope: String, + nonce: Option, + ) -> Self { + Self { + id: None, + user, + code, + client_id, + state, + scope, + redirect_uri, + nonce, + } + } + + /// Get client by code + pub async fn find_by_code(pool: &DbPool, code: &str) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", \"user\", code, client_id, state, scope, redirect_uri, nonce \ + FROM openidclientauthcode WHERE code = $1", + code + ) + .fetch_optional(pool) + .await + } +} + +#[derive(Deserialize, Model, Serialize)] +#[table(authorizedapps)] +pub struct AuthorizedApp { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub user_id: i64, + pub client_id: String, + pub home_url: String, + pub date: String, // TODO: NaiveDateTime %d-%m-%Y %H:%M + pub name: String, +} + +impl AuthorizedApp { + #[must_use] + pub fn new( + user_id: i64, + client_id: String, + home_url: String, + date: String, + name: String, + ) -> Self { + Self { + id: None, + user_id, + client_id, + home_url, + date, + name, + } + } + + pub async fn find_by_user_and_client_id( + pool: &DbPool, + user_id: i64, + client_id: &str, + ) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", user_id, client_id, home_url, date, name \ + FROM authorizedapps WHERE user_id = $1 AND client_id = $2", + user_id, + client_id + ) + .fetch_optional(pool) + .await + } + + pub async fn all_for_user(pool: &DbPool, user_id: i64) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", user_id, client_id, home_url, date, name \ + FROM authorizedapps WHERE user_id = $1", + user_id + ) + .fetch_all(pool) + .await + } +} diff --git a/src/enterprise/grpc/mod.rs b/src/enterprise/grpc/mod.rs new file mode 100644 index 000000000..8c289a0ad --- /dev/null +++ b/src/enterprise/grpc/mod.rs @@ -0,0 +1,43 @@ +use crate::db::AppEvent; +use serde::Serialize; +use std::{collections::hash_map::HashMap, net::IpAddr, time::Instant}; +use tokio::sync::mpsc::UnboundedSender; + +pub mod worker; + +pub struct Job { + id: u32, + first_name: String, + last_name: String, + email: String, + username: String, +} + +#[derive(Serialize)] +pub struct JobResponse { + pub success: bool, + pgp_key: String, + pgp_cert_id: String, + ssh_key: String, + pub error: String, +} + +pub struct WorkerInfo { + last_seen: Instant, + ip: IpAddr, + jobs: Vec, +} + +pub struct WorkerState { + current_job_id: u32, + workers: HashMap, + job_status: HashMap, + webhook_tx: UnboundedSender, +} + +#[derive(Serialize)] +pub struct WorkerDetail { + id: String, + ip: IpAddr, + connected: bool, +} diff --git a/src/enterprise/grpc/worker.rs b/src/enterprise/grpc/worker.rs new file mode 100644 index 000000000..8adca7f49 --- /dev/null +++ b/src/enterprise/grpc/worker.rs @@ -0,0 +1,282 @@ +use super::{Job, JobResponse, WorkerDetail, WorkerInfo, WorkerState}; +use crate::db::{AppEvent, DbPool, HWKeyUserData, User}; +use std::{ + collections::hash_map::{Entry, HashMap}, + env, + net::{IpAddr, Ipv4Addr}, + sync::{Arc, Mutex}, + time::Instant, +}; +use tokio::sync::mpsc::UnboundedSender; +use tonic::{Request, Response, Status}; + +tonic::include_proto!("worker"); + +impl WorkerInfo { + /// Create new `Worker` instance. + #[must_use] + pub fn new() -> Self { + Self { + last_seen: Instant::now(), + ip: IpAddr::V4(Ipv4Addr::UNSPECIFIED), + jobs: Vec::new(), + } + } + + /// Update connectivity timer. + pub fn refresh_status(&mut self) { + self.last_seen = Instant::now(); + } + + /// Connectivity status. + pub fn connected(&self) -> bool { + self.last_seen.elapsed().as_secs() < 2 + } + + /// Return first availale Job. + pub fn get_job(&self) -> Option<&Job> { + self.jobs.first() + } + + /// Set worker ip + pub fn set_ip(&mut self, ip: IpAddr) { + self.ip = ip; + } + + /// Add Job. + pub fn add_job(&mut self, job: Job) { + self.jobs.push(job); + } + + /// Remove Job with given id. + pub fn remove_job_with_id(&mut self, job_id: u32) -> Option { + if let Some(index) = self.jobs.iter().position(|job| job.id == job_id) { + Some(self.jobs.remove(index)) + } else { + None + } + } +} + +impl Default for WorkerInfo { + fn default() -> Self { + Self::new() + } +} + +impl WorkerState { + /// Return initial state. + #[must_use] + pub fn new(webhook_tx: UnboundedSender) -> Self { + Self { + current_job_id: 1, + workers: HashMap::new(), + job_status: HashMap::new(), + webhook_tx, + } + } + + /// Return `true` on success. + pub fn register_worker(&mut self, id: String) -> bool { + if let Entry::Vacant(entry) = self.workers.entry(id) { + entry.insert(WorkerInfo::new()); + true + } else { + false + } + } + + /// Create a new job. + /// Return job id. + pub fn create_job( + &mut self, + worker_id: &str, + first_name: String, + last_name: String, + email: String, + username: String, + ) -> u32 { + if let Some(worker) = self.workers.get_mut(worker_id) { + let id = self.current_job_id; + self.current_job_id = id.wrapping_add(1); + worker.add_job(Job { + id, + first_name, + last_name, + email, + username, + }); + id + } else { + 0 + } + } + + /// Remove a job for a given worker. + pub fn remove_job(&mut self, id: &str, job_id: u32) -> Option { + if let Some(worker) = self.workers.get_mut(id) { + worker.refresh_status(); + worker.remove_job_with_id(job_id) + } else { + None + } + } + + /// Return the first available job. + pub fn get_job(&mut self, id: &str, ip: IpAddr) -> Option<&Job> { + if let Some(worker) = self.workers.get_mut(id) { + worker.refresh_status(); + worker.set_ip(ip); + worker.get_job() + } else { + None + } + } + + pub fn list_workers(&self) -> Vec { + let mut w = Vec::new(); + for (id, worker) in &self.workers { + let workers = WorkerDetail { + id: id.clone(), + ip: worker.ip, + connected: worker.connected(), + }; + w.push(workers); + } + w + } + + pub fn remove_worker(&mut self, id: &str) -> bool { + self.workers.remove_entry(id).is_some() + } + + pub fn set_job_status( + &mut self, + job_id: u32, + success: bool, + pgp_key: String, + pgp_cert_id: String, + ssh_key: String, + error: String, + ) { + self.job_status.insert( + job_id, + JobResponse { + success, + pgp_key, + pgp_cert_id, + ssh_key, + error, + }, + ); + } + + pub fn get_job_status(&self, job_id: u32) -> Option<&JobResponse> { + self.job_status.get(&job_id) + } +} + +pub struct WorkerServer { + pool: DbPool, + state: Arc>, +} + +impl WorkerServer { + #[must_use] + pub fn new(pool: DbPool, state: Arc>) -> Self { + Self { pool, state } + } +} + +pub fn token_interceptor(req: Request<()>) -> Result, Status> { + if let Some(token) = req.metadata().get("worker-token") { + if let Ok(token) = token.to_str() { + if token == env::var("DEFGUARD_WORKER_TOKEN").unwrap_or_else(|_| "worker-secret".into()) + { + return Ok(req); + } + } + } + + Err(Status::unauthenticated("Invalid token")) +} + +#[tonic::async_trait] +impl worker_service_server::WorkerService for WorkerServer { + async fn register_worker(&self, request: Request) -> Result, Status> { + let message = request.into_inner(); + let mut state = self.state.lock().unwrap(); + if state.register_worker(String::from(&message.id)) { + debug!("Added worker with id: {}", message.id); + Ok(Response::new(())) + } else { + Err(Status::already_exists("Worker already registered")) + } + } + + async fn get_job(&self, request: Request) -> Result, Status> { + let ip = request + .remote_addr() + .map_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED), |addr| addr.ip()); + let message = request.into_inner(); + let mut state = self.state.lock().unwrap(); + if let Some(job) = state.get_job(&message.id, ip) { + Ok(Response::new(GetJobResponse { + first_name: job.first_name.clone(), + last_name: job.last_name.clone(), + email: job.email.clone(), + job_id: job.id, + })) + } else { + Err(Status::not_found("No more jobs")) + } + } + + async fn set_job_done(&self, request: Request) -> Result, Status> { + let message = request.into_inner(); + // Remove job and set status + if let Some(job_done) = { + let mut state = self.state.lock().unwrap(); + state.set_job_status( + message.job_id, + message.success, + message.public_key.clone(), + message.fingerprint.clone(), + message.ssh_key.clone(), + message.error, + ); + #[allow(clippy::let_and_return)] + let job = state.remove_job(&message.id, message.job_id); + job + } { + if message.success { + { + // FIXME: locked again + let state = self.state.lock().unwrap(); + let _ = state + .webhook_tx + .send(AppEvent::HWKeyProvision(HWKeyUserData { + username: job_done.username.clone(), + email: job_done.email.clone(), + ssh_key: message.ssh_key.clone(), + pgp_key: message.public_key.clone(), + pgp_cert_id: message.fingerprint.clone(), + })); + } + match User::find_by_username(&self.pool, &job_done.username).await { + Ok(Some(mut user)) => { + user.ssh_key = Some(message.ssh_key); + user.pgp_key = Some(message.public_key); + user.pgp_cert_id = Some(message.fingerprint); + user.save(&self.pool) + .await + .map_err(|_| Status::internal("database error"))?; + } + Ok(None) => info!("User {} not found", job_done.username), + Err(err) => error!("Error {}", err), + } + } + } + Ok(Response::new(())) + } +} diff --git a/src/enterprise/handlers/mod.rs b/src/enterprise/handlers/mod.rs new file mode 100644 index 000000000..ae60f3c12 --- /dev/null +++ b/src/enterprise/handlers/mod.rs @@ -0,0 +1,8 @@ +#[cfg(feature = "oauth")] +pub mod oauth; +#[cfg(feature = "openid")] +pub mod openid_clients; +#[cfg(feature = "openid")] +pub mod openid_flow; +#[cfg(feature = "worker")] +pub mod worker; diff --git a/src/enterprise/handlers/oauth.rs b/src/enterprise/handlers/oauth.rs new file mode 100644 index 000000000..5b4e524dd --- /dev/null +++ b/src/enterprise/handlers/oauth.rs @@ -0,0 +1,64 @@ +use crate::{ + enterprise::oauth_state::OAuthState, + oxide_auth_rocket::{OAuthFailure, OAuthRequest, OAuthResponse}, +}; +use oxide_auth_async::endpoint::{ + access_token::AccessTokenFlow, authorization::AuthorizationFlow, refresh::RefreshFlow, +}; +use rocket::{Data, State}; + +#[get("/authorize")] +pub async fn authorize<'r>( + oauth: OAuthRequest<'r>, + state: &State, +) -> Result, OAuthFailure> { + let mut flow = match AuthorizationFlow::prepare(state.inner().clone()) { + Err(_) => unreachable!(), + Ok(flow) => flow, + }; + flow.execute(oauth).await +} + +#[post("/authorize?")] +pub async fn authorize_consent<'r>( + oauth: OAuthRequest<'r>, + allow: Option, + state: &State, +) -> Result, OAuthFailure> { + let mut endpoint = state.inner().clone(); + endpoint.allow = allow.unwrap_or(false); + endpoint.decision = true; + let mut flow = match AuthorizationFlow::prepare(endpoint) { + Err(_) => unreachable!(), + Ok(flow) => flow, + }; + flow.execute(oauth).await +} + +#[post("/token", data = "")] +pub async fn token<'r>( + mut oauth: OAuthRequest<'r>, + body: Data<'_>, + state: &State, +) -> Result, OAuthFailure> { + oauth.add_body(body).await; + let mut flow = match AccessTokenFlow::prepare(state.inner().clone()) { + Err(_) => unreachable!(), + Ok(flow) => flow, + }; + flow.execute(oauth).await +} + +#[post("/refresh", data = "")] +pub async fn refresh<'r>( + mut oauth: OAuthRequest<'r>, + body: Data<'_>, + state: &State, +) -> Result, OAuthFailure> { + oauth.add_body(body).await; + let mut flow = match RefreshFlow::prepare(state.inner().clone()) { + Err(_) => unreachable!(), + Ok(flow) => flow, + }; + flow.execute(oauth).await +} diff --git a/src/enterprise/handlers/openid_clients.rs b/src/enterprise/handlers/openid_clients.rs new file mode 100644 index 000000000..4a6bb448a --- /dev/null +++ b/src/enterprise/handlers/openid_clients.rs @@ -0,0 +1,178 @@ +use crate::{ + appstate::AppState, + auth::SessionInfo, + enterprise::db::openid::{AuthorizedApp, NewOpenIDClient, OpenIDClient}, + handlers::{webhooks::ChangeStateData, ApiResponse, ApiResult}, +}; +use rocket::{ + http::Status, + serde::json::{serde_json::json, Json}, + State, +}; + +#[post("/", format = "json", data = "")] +pub async fn add_openid_client( + _session: SessionInfo, + appstate: &State, + data: Json, +) -> ApiResult { + let mut client: OpenIDClient = data.into_inner().into(); + client.save(&appstate.pool).await?; + Ok(ApiResponse { + json: json!(client), + status: Status::Created, + }) +} + +#[get("/", format = "json")] +pub async fn list_openid_clients(_session: SessionInfo, appstate: &State) -> ApiResult { + debug!("Listing OpenID clients"); + let openid_clients = OpenIDClient::all(&appstate.pool).await?; + Ok(ApiResponse { + json: json!(openid_clients), + status: Status::Ok, + }) +} + +#[get("/", format = "json")] +pub async fn get_openid_client( + _session: SessionInfo, + appstate: &State, + id: i64, +) -> ApiResult { + match OpenIDClient::find_by_id(&appstate.pool, id).await? { + Some(openid_client) => Ok(ApiResponse { + json: json!(openid_client), + status: Status::Ok, + }), + None => Ok(ApiResponse { + json: json!({}), + status: Status::NotFound, + }), + } +} + +#[put("/", format = "json", data = "")] +pub async fn change_openid_client( + _session: SessionInfo, + appstate: &State, + id: i64, + data: Json, +) -> ApiResult { + let status = match OpenIDClient::find_by_id(&appstate.pool, id).await? { + Some(mut openid_client) => { + let data = data.into_inner(); + openid_client.name = data.name; + openid_client.description = data.description; + openid_client.home_url = data.home_url; + openid_client.redirect_uri = data.redirect_uri; + openid_client.enabled = data.enabled; + openid_client.save(&appstate.pool).await?; + Status::Ok + } + None => Status::NotFound, + }; + Ok(ApiResponse { + json: json!({}), + status, + }) +} + +#[post("/", format = "json", data = "")] +pub async fn change_openid_client_state( + _session: SessionInfo, + appstate: &State, + id: i64, + data: Json, +) -> ApiResult { + let status = match OpenIDClient::find_by_id(&appstate.pool, id).await? { + Some(mut openid_client) => { + openid_client.enabled = data.enabled; + Status::Ok + } + None => Status::NotFound, + }; + Ok(ApiResponse { + json: json!({}), + status, + }) +} + +#[delete("/")] +pub async fn delete_openid_client( + _session: SessionInfo, + appstate: &State, + id: i64, +) -> ApiResult { + debug!("Removing OpenID client with id: {}", id); + let status = match OpenIDClient::find_by_id(&appstate.pool, id).await? { + Some(openid_client) => { + openid_client.delete(&appstate.pool).await?; + Status::Ok + } + None => Status::NotFound, + }; + Ok(ApiResponse { + json: json!({}), + status, + }) +} + +#[get("/apps/")] +pub async fn get_user_apps( + session_info: SessionInfo, + appstate: &State, + username: &str, +) -> ApiResult { + debug!("Listing apps authorized by user: {}", username); + let apps = AuthorizedApp::all_for_user(&appstate.pool, session_info.user.id.unwrap()).await?; + Ok(ApiResponse { + json: json!(apps), + status: Status::Ok, + }) +} + +#[put("/apps/", format = "json", data = "")] +pub async fn update_user_app( + _session: SessionInfo, + appstate: &State, + id: i64, + data: Json, +) -> ApiResult { + let status = match AuthorizedApp::find_by_id(&appstate.pool, id).await? { + Some(mut app) => { + let update = data.into_inner(); + app.client_id = update.client_id; + app.home_url = update.home_url; + app.date = update.date; + app.name = update.name; + app.save(&appstate.pool).await?; + Status::Ok + } + None => Status::NotFound, + }; + Ok(ApiResponse { + json: json!({}), + status, + }) +} + +#[delete("/apps/")] +pub async fn delete_user_app( + _session: SessionInfo, + appstate: &State, + id: i64, +) -> ApiResult { + debug!("Removing authorized app with id: {}", id); + let status = match AuthorizedApp::find_by_id(&appstate.pool, id).await? { + Some(app) => { + app.delete(&appstate.pool).await?; + Status::Ok + } + None => Status::NotFound, + }; + Ok(ApiResponse { + json: json!({}), + status, + }) +} diff --git a/src/enterprise/handlers/openid_flow.rs b/src/enterprise/handlers/openid_flow.rs new file mode 100644 index 000000000..c012e2917 --- /dev/null +++ b/src/enterprise/handlers/openid_flow.rs @@ -0,0 +1,173 @@ +use crate::{ + appstate::AppState, + auth::SessionInfo, + db::{Session, User}, + enterprise::{ + db::openid::{AuthorizedApp, OpenIDClient, OpenIDClientAuth}, + openid_idtoken::IDTokenClaims, + openid_state::OpenIDRequest, + }, + error::OriWebError, + handlers::{ApiResponse, ApiResult}, +}; +use rocket::{ + form::{Form, Lenient}, + http::Status, + response::Redirect, + serde::json::{serde_json::json, Json}, + State, +}; + +// Check if app is authorized, return 200 or 404 +#[post("/verify", format = "json", data = "")] +pub async fn check_authorized( + session: Session, + data: Json, + appstate: &State, +) -> ApiResult { + let status = match AuthorizedApp::find_by_user_and_client_id( + &appstate.pool, + session.user_id, + &data.client_id, + ) + .await? + { + Some(_app) => Status::Ok, + None => Status::NotFound, + }; + Ok(ApiResponse { + json: json!({}), + status, + }) +} + +// Login endpoint redirect with authorization code on success, or error if something failed +// https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthorizationEndpoint +// Generate PKCE code challenge, store in the database +// and return 302 redirect for given URL with state and code +#[post("/authorize?")] +pub async fn authentication_request( + session: SessionInfo, + data: Lenient, + appstate: &State, +) -> Result { + let openid_request = data.into_inner(); + debug!("Verifying client: {}", openid_request.client_id); + openid_request + .create_code( + &appstate.pool, + &session.user.username, + session.user.id.unwrap(), + ) + .await +} + +#[derive(FromForm)] +pub struct IDTokenRequest { + pub grant_type: String, + pub code: String, + pub redirect_uri: String, +} + +// Create token with scopes based on client +#[post("/token", data = "

")] +pub async fn id_token(form: Form, appstate: &State) -> ApiResult { + debug!("Verifying authorization code: {}", &form.code); + if let Some(client) = OpenIDClientAuth::find_by_code(&appstate.pool, &form.code).await? { + // Check user session and create id token + debug!("Checking session for user: {}", &client.user); + if let Some(user) = User::find_by_username(&appstate.pool, &client.user).await? { + // Create user claims based on scope + let user_claims = IDTokenClaims::get_user_claims(user, &client.scope); + let secret = + match OpenIDClient::find_enabled_for_client_id(&appstate.pool, &client.client_id) + .await? + { + Some(client) => client.client_secret, + None => { + return Err(OriWebError::ObjectNotFound( + "Failed to find client secret corresponding to id".to_string(), + )); + } + }; + debug!("Creating ID Token for {}", &client.user); + let token = IDTokenClaims::new( + user_claims.username.clone(), + client.client_id.clone(), + client.nonce.clone(), + user_claims, + ) + .to_jwt(&secret) + .map_err(|_| OriWebError::Authorization("Failed to create ID token".to_string()))?; + info!("ID Token for user {} created", &client.user); + + // Remove client authorization code from database + // FIXME: this used to the first statement in this function -- check if it is valid here + client + .delete(&appstate.pool) + .await + .map_err(|_| OriWebError::ObjectNotFound("Failed to remove client".into()))?; + + Ok(ApiResponse { + json: json!({ "id_token": token }), + status: Status::Ok, + }) + } else { + Ok(ApiResponse { + json: json!({ + "error": + "failed to get user session", + }), + status: Status::BadRequest, + }) + } + } else { + Ok(ApiResponse { + json: json!({"error": "failed to authorize client"}), + status: Status::BadRequest, + }) + } +} + +#[derive(Serialize)] +pub struct OpenIDConfiguration { + issuer: String, + authorization_endpoint: String, + token_endpoint: String, + scopes_supported: Vec, + response_types_supported: Vec, + claims_supported: Vec, +} + +#[get("/.well-known/openid-configuration", format = "json")] +pub fn openid_configuration(appstate: &State) -> ApiResult { + let openid_config = OpenIDConfiguration { + issuer: appstate.config.url.clone(), + authorization_endpoint: format!("{}/openid/authorize", appstate.config.url), + token_endpoint: format!("{}/api/openid/token", appstate.config.url), + scopes_supported: vec![ + "openid".into(), + "profile".into(), + "email".into(), + "phone".into(), + ], + response_types_supported: vec!["code".into()], + claims_supported: vec![ + "iss".into(), + "sub".into(), + "exp".into(), + "iat".into(), + "given_name".into(), + "family_name".into(), + "email".into(), + "email_verified".into(), + "phone".into(), + "phone_verified".into(), + "nonce".into(), + ], + }; + Ok(ApiResponse { + json: json!(openid_config), + status: Status::Ok, + }) +} diff --git a/src/enterprise/handlers/worker.rs b/src/enterprise/handlers/worker.rs new file mode 100644 index 000000000..0ee620b10 --- /dev/null +++ b/src/enterprise/handlers/worker.rs @@ -0,0 +1,124 @@ +use crate::{ + appstate::AppState, + auth::SessionInfo, + db::User, + enterprise::grpc::WorkerState, + error::OriWebError, + handlers::{ApiResponse, ApiResult}, +}; + +use rocket::{ + http::Status, + serde::json::{serde_json::json, Json}, + State, +}; +use std::sync::{Arc, Mutex}; + +#[derive(Deserialize)] +pub struct JobData { + pub username: String, + pub worker: String, +} + +#[derive(Serialize)] +pub struct Jobid { + pub id: u32, +} + +#[derive(Serialize)] +struct JobResponseError { + message: String, +} + +#[post("/job", format = "json", data = "")] +pub async fn create_job( + _session: SessionInfo, + appstate: &State, + data: Json, + worker_state: &State>>, +) -> ApiResult { + let job_data = data.into_inner(); + match User::find_by_username(&appstate.pool, &job_data.username).await? { + Some(user) => { + let mut state = worker_state.lock().unwrap(); + debug!("Creating job"); + let id = state.create_job( + &job_data.worker, + user.first_name.clone(), + user.last_name.clone(), + user.email, + job_data.username, + ); + info!("Job created with id {}", id); + Ok(ApiResponse { + json: json!(Jobid { id }), + status: Status::Created, + }) + } + None => Err(OriWebError::ObjectNotFound(format!( + "user {} not found", + job_data.username + ))), + } +} + +#[get("/", format = "json")] +pub fn list_workers( + _session: SessionInfo, + worker_state: &State>>, +) -> ApiResult { + let state = worker_state.lock().unwrap(); + let workers = state.list_workers(); + Ok(ApiResponse { + json: json!(workers), + status: Status::Ok, + }) +} + +#[delete("/")] +pub async fn remove_worker( + _session: SessionInfo, + worker_state: &State>>, + worker_id: &str, +) -> ApiResult { + let mut state = worker_state.lock().unwrap(); + if state.remove_worker(worker_id) { + Ok(ApiResponse::default()) + } else { + error!("Worker {} not found", worker_id); + Err(OriWebError::ObjectNotFound(format!( + "worker_id {} not found", + worker_id + ))) + } +} + +#[get("/", format = "json")] +pub async fn job_status( + _session: SessionInfo, + worker_state: &State>>, + job_id: u32, +) -> ApiResult { + let state = worker_state.lock().unwrap(); + let job_response = state.get_job_status(job_id); + if job_response.is_some() { + if job_response.unwrap().success { + Ok(ApiResponse { + json: json!(job_response), + status: Status::Ok, + }) + } else { + Ok(ApiResponse { + json: json!(JobResponseError { + message: job_response.unwrap().error.clone() + }), + status: Status::NotFound, + }) + } + } else { + Ok(ApiResponse { + json: json!(job_response), + status: Status::Ok, + }) + } +} diff --git a/src/enterprise/ldap/error.rs b/src/enterprise/ldap/error.rs new file mode 100644 index 000000000..171e0e0cd --- /dev/null +++ b/src/enterprise/ldap/error.rs @@ -0,0 +1,25 @@ +use ldap3::LdapError; +use std::{error::Error, fmt}; + +#[derive(Debug)] +pub enum OriLDAPError { + Ldap(String), + ObjectNotFound(String), +} + +impl fmt::Display for OriLDAPError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OriLDAPError::Ldap(msg) => write!(f, "LDAP error: {}", msg), + OriLDAPError::ObjectNotFound(msg) => write!(f, "Object not found: {}", msg), + } + } +} + +impl Error for OriLDAPError {} + +impl From for OriLDAPError { + fn from(error: LdapError) -> Self { + Self::Ldap(error.to_string()) + } +} diff --git a/src/enterprise/ldap/hash.rs b/src/enterprise/ldap/hash.rs new file mode 100644 index 000000000..a14761db6 --- /dev/null +++ b/src/enterprise/ldap/hash.rs @@ -0,0 +1,46 @@ +use md4::Md4; +use rand_core::{OsRng, RngCore}; +use sha1::{ + digest::generic_array::{sequence::Concat, GenericArray}, + Digest, Sha1, +}; + +/// Calculate salted SHA1 hash from given password in SSHA password storage scheme. +#[must_use] +pub fn salted_sha1_hash(password: &str) -> String { + // random bytes + let mut salt = [0u8; 4]; + OsRng.fill_bytes(&mut salt); + + let mut pass = Vec::from(password); + pass.extend_from_slice(&salt); + + let checksum = Sha1::digest(pass); + let checksum = checksum.concat(GenericArray::from(salt)); + + format!("{{SSHA}}{}", base64::encode(checksum)) +} + +/// Calculate Windows NT-HASH; used for `sambaNTPassword`. +#[must_use] +pub fn nthash(password: &str) -> String { + let password_utf16_le: Vec = password + .encode_utf16() + .flat_map(|c| IntoIterator::into_iter(c.to_le_bytes())) + .collect(); + format!("{:x}", Md4::digest(password_utf16_le)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[std::prelude::v1::test] + fn test_hash() { + assert_eq!(nthash("password"), "8846f7eaee8fb117ad06bdd830b7586c"); + assert_eq!( + nthash("Zażółć gęślą jaźń"), + "d8aaaa749c60362557d56f330f6ae217" + ); + } +} diff --git a/src/enterprise/ldap/mod.rs b/src/enterprise/ldap/mod.rs new file mode 100644 index 000000000..f575676f5 --- /dev/null +++ b/src/enterprise/ldap/mod.rs @@ -0,0 +1,280 @@ +use crate::{config::DefGuardConfig, db::User}; +use error::OriLDAPError; +use ldap3::{drive, Ldap, LdapConnAsync, Mod, Scope, SearchEntry}; +use model::Group; +use std::collections::HashSet; + +pub mod error; +pub mod hash; +pub mod model; +pub mod utils; + +#[macro_export] +macro_rules! hashset { + ( $( $element:expr ),* ) => { + { + let mut map = HashSet::new(); + $( + map.insert($element); + )* + map + } + }; +} + +pub struct LDAPConnection<'a> { + config: &'a DefGuardConfig, + ldap: Ldap, +} + +impl<'a> LDAPConnection<'a> { + pub async fn create(config: &'a DefGuardConfig) -> Result, OriLDAPError> { + let (conn, mut ldap) = LdapConnAsync::new(&config.ldap_url).await?; + drive!(conn); + info!("Connected to LDAP: {}", &config.ldap_url); + ldap.simple_bind(&config.ldap_bind_username, &config.ldap_bind_password) + .await? + .success()?; + Ok(Self { config, ldap }) + } + + /// Searches LDAP for users. + async fn search_users(&mut self, filter: &str) -> Result, OriLDAPError> { + let (rs, _res) = self + .ldap + .search( + &self.config.ldap_user_search_base, + Scope::Subtree, + filter, + vec!["*", &self.config.ldap_member_attr], + ) + .await? + .success()?; + info!("Performed LDAP user search with filter = {}", filter); + Ok(rs.into_iter().map(SearchEntry::construct).collect()) + } + + /// Searches LDAP for groups. + async fn search_groups(&mut self, filter: &str) -> Result, OriLDAPError> { + let (rs, _res) = self + .ldap + .search( + &self.config.ldap_group_search_base, + Scope::Subtree, + filter, + vec![ + &self.config.ldap_username_attr, + &self.config.ldap_group_member_attr, + ], + ) + .await? + .success()?; + info!("Performed LDAP group search with filter = {}", filter); + Ok(rs.into_iter().map(SearchEntry::construct).collect()) + } + + /// Creates LDAP object with specified distinguished name and attributes. + async fn add( + &mut self, + dn: &str, + attrs: Vec<(&str, HashSet<&str>)>, + ) -> Result<(), OriLDAPError> { + debug!("Adding object {}", dn); + self.ldap.add(dn, attrs).await?.success()?; + info!("Added object {}", dn); + Ok(()) + } + + /// Updates LDAP object with specified distinguished name and attributes. + async fn modify( + &mut self, + old_dn: &str, + new_dn: &str, + mods: Vec>, + ) -> Result<(), OriLDAPError> { + debug!("Modifying object {}", old_dn); + self.ldap.modify(old_dn, mods).await?; + if old_dn != new_dn { + if let Some((new_rdn, _rest)) = new_dn.split_once(',') { + self.ldap.modifydn(old_dn, new_rdn, true, None).await?; + } + } + info!("Modified object {}", old_dn); + Ok(()) + } + + /// Deletes LDAP object with specified distinguished name. + pub async fn delete(&mut self, dn: &str) -> Result<(), OriLDAPError> { + debug!("Deleting object {}", dn); + self.ldap.delete(dn).await?; + info!("Deleted object {}", dn); + Ok(()) + } + + // Checks if cn is available, including default LDAP admin class + pub async fn is_username_available(&mut self, username: &str) -> bool { + let users = self + .search_users(&format!( + "(&({}={})(|(objectClass={})))", + self.config.ldap_username_attr, username, self.config.ldap_user_obj_class + )) + .await; + match users { + Ok(users) => users.is_empty(), + _ => true, + } + } + + /// Retrieves user with given username from LDAP. + /// TODO: Password must agree with the password stored in LDAP. + pub async fn get_user(&mut self, username: &str, password: &str) -> Result { + debug!("Performing LDAP user search: {}", username); + let mut entries = self + .search_users(&format!( + "(&({}={})(objectClass={}))", + self.config.ldap_username_attr, username, self.config.ldap_user_obj_class + )) + .await?; + if let Some(entry) = entries.pop() { + info!("Performed LDAP user search: {}", username); + Ok(User::from_searchentry(&entry, username, password)) + } else { + Err(OriLDAPError::ObjectNotFound(format!( + "User {} not found", + username + ))) + } + } + + /// Adds user to LDAP. + pub async fn add_user(&mut self, user: &User, password: &str) -> Result<(), OriLDAPError> { + debug!("Adding LDAP user {}", user.username); + let dn = self.config.user_dn(&user.username); + let ssha_password = hash::salted_sha1_hash(password); + let ht_password = hash::nthash(password); + self.add(&dn, user.as_ldap_attrs(&ssha_password, &ht_password)) + .await?; + info!("Added LDAP user {}", user.username); + Ok(()) + } + + /// Modifies LDAP user. + pub async fn modify_user(&mut self, username: &str, user: &User) -> Result<(), OriLDAPError> { + debug!("Modifying user {}", username); + let old_dn = self.config.user_dn(username); + let new_dn = self.config.user_dn(&user.username); + self.modify(&old_dn, &new_dn, user.as_ldap_mod(self.config)) + .await?; + info!("Modified user {}", username); + Ok(()) + } + + /// Deletes user from LDAP. + pub async fn delete_user(&mut self, username: &str) -> Result<(), OriLDAPError> { + debug!("Deleting user {}", username); + let dn = self.config.user_dn(username); + self.delete(&dn).await?; + info!("Deleted user {}", username); + Ok(()) + } + + /// Changes user password. + pub async fn set_password( + &mut self, + username: &str, + password: &str, + ) -> Result<(), OriLDAPError> { + debug!("Setting password for user {}", username); + let user_dn = self.config.user_dn(username); + let ssha_password = hash::salted_sha1_hash(password); + let nt_password = hash::nthash(password); + self.modify( + &user_dn, + &user_dn, + vec![ + Mod::Replace("userPassword", hashset![ssha_password.as_str()]), + Mod::Replace("sambaNTPassword", hashset![nt_password.as_str()]), + ], + ) + .await?; + info!("Password set for user {}", username); + Ok(()) + } + + /// Retrieves group with given groupname from LDAP. + pub async fn get_group(&mut self, groupname: &str) -> Result { + debug!("Performing LDAP group search: {}", groupname); + let mut enties = self + .search_groups(&format!( + "(&({}={})(objectClass={}))", + self.config.ldap_groupname_attr, groupname, self.config.ldap_group_obj_class + )) + .await?; + if let Some(entry) = enties.pop() { + info!("Performed LDAP user search: {}", groupname); + Ok(Group::from_searchentry(&entry, self.config)) + } else { + Err(OriLDAPError::ObjectNotFound(format!( + "Group {} not found", + groupname + ))) + } + } + + /// Lists users satisfying specified criteria + pub async fn get_groups(&mut self) -> Result, OriLDAPError> { + debug!("Performing LDAP group search"); + let mut entries = self + .search_groups(&format!( + "(objectClass={})", + self.config.ldap_group_obj_class + )) + .await?; + let users = entries + .drain(..) + .map(|entry| Group::from_searchentry(&entry, self.config)) + .collect(); + info!("Performed LDAP group search"); + Ok(users) + } + + /// Add user to a group. + pub async fn add_user_to_group( + &mut self, + username: &str, + groupname: &str, + ) -> Result<(), OriLDAPError> { + let user_dn = self.config.user_dn(username); + let group_dn = self.config.group_dn(groupname); + self.modify( + &group_dn, + &group_dn, + vec![Mod::Add( + &self.config.ldap_group_member_attr.clone(), + hashset![user_dn.as_str()], + )], + ) + .await?; + Ok(()) + } + + /// Remove user from a group. + pub async fn remove_user_from_group( + &mut self, + username: &str, + groupname: &str, + ) -> Result<(), OriLDAPError> { + let user_dn = self.config.user_dn(username); + let group_dn = self.config.group_dn(groupname); + self.modify( + &group_dn, + &group_dn, + vec![Mod::Delete( + &self.config.ldap_group_member_attr.clone(), + hashset![user_dn.as_str()], + )], + ) + .await?; + Ok(()) + } +} diff --git a/src/enterprise/ldap/model.rs b/src/enterprise/ldap/model.rs new file mode 100644 index 000000000..8cb45e6c7 --- /dev/null +++ b/src/enterprise/ldap/model.rs @@ -0,0 +1,126 @@ +use crate::{config::DefGuardConfig, db::User, hashset}; +use ldap3::{Mod, SearchEntry}; +use std::collections::HashSet; + +impl User { + pub fn from_searchentry(entry: &SearchEntry, username: &str, password: &str) -> Self { + Self::new( + username.into(), + password, + get_value_or_default(entry, "sn"), + get_value_or_default(entry, "givenName"), + get_value_or_default(entry, "mail"), + get_value(entry, "mobile"), + ) + } + + pub fn as_ldap_mod(&self, config: &DefGuardConfig) -> Vec> { + let mut changes = vec![ + Mod::Replace("sn", hashset![self.last_name.as_str()]), + Mod::Replace("givenName", hashset![self.first_name.as_str()]), + Mod::Replace("mail", hashset![self.email.as_str()]), + ]; + if let Some(ref phone) = self.phone { + changes.push(Mod::Replace("mobile", hashset![phone.as_str()])); + } + // Be careful when changing naming attribute (the one in distingushed name) + if config.ldap_username_attr != "cn" { + changes.push(Mod::Replace("cn", hashset![self.username.as_str()])); + } + if config.ldap_username_attr != "uid" { + changes.push(Mod::Replace("uid", hashset![self.username.as_str()])); + } + changes + } + + pub fn as_ldap_attrs<'a>( + &'a self, + ssha_password: &'a str, + nt_password: &'a str, + ) -> Vec<(&'a str, HashSet<&'a str>)> { + let mut attrs = vec![ + ( + "objectClass", + hashset!["inetOrgPerson", "simpleSecurityObject", "sambaSamAccount"], + ), + // inetOrgPerson + ("cn", hashset![self.username.as_str()]), + ("sn", hashset![self.last_name.as_str()]), + ("givenName", hashset![self.first_name.as_str()]), + ("mail", hashset![self.email.as_str()]), + ("uid", hashset![self.username.as_str()]), + // simpleSecurityObject + ("userPassword", hashset![ssha_password]), + // sambaSamAccount + ("sambaSID", hashset!["0"]), + ("sambaNTPassword", hashset![nt_password]), + ]; + if let Some(ref phone) = self.phone { + attrs.push(("mobile", hashset![phone.as_str()])); + } + attrs + } +} + +pub struct Group { + pub name: String, + pub members: Vec, +} + +impl Group { + pub fn from_searchentry(entry: &SearchEntry, config: &DefGuardConfig) -> Self { + Self { + name: get_value_or_default(entry, &config.ldap_groupname_attr), + members: match entry.attrs.get(&config.ldap_group_member_attr) { + Some(members) => members + .iter() + .filter_map(|member| extract_dn_value(member)) + .collect(), + None => Vec::new(), + }, + } + } +} + +fn get_value_or_default(entry: &SearchEntry, key: &str) -> String { + match entry.attrs.get(key) { + Some(values) if !values.is_empty() => values[0].clone(), + _ => String::default(), + } +} + +fn get_value(entry: &SearchEntry, key: &str) -> Option { + match entry.attrs.get(key) { + Some(values) if !values.is_empty() => Some(values[0].clone()), + _ => None, + } +} + +/// Get first value from distinguished name, for example: cn=,... +pub fn extract_dn_value(dn: &str) -> Option { + if let (Some(eq_index), Some(comma_index)) = (dn.find('='), dn.find(',')) { + dn.get((eq_index + 1)..comma_index).map(|s| s.to_string()) + } else { + None + } +} + +impl<'a> From<&'a User> for Vec<(&'a str, HashSet<&'a str>)> { + fn from(user: &'a User) -> Self { + let mut attrs = vec![ + ( + "objectClass", + hashset!["inetOrgPerson", "simpleSecurityObject"], + ), + ("cn", hashset![user.username.as_str()]), + ("sn", hashset![user.last_name.as_str()]), + ("givenName", hashset![user.first_name.as_str()]), + ("mail", hashset![user.email.as_str()]), + ("uid", hashset![user.username.as_str()]), + ]; + if let Some(ref phone) = user.phone { + attrs.push(("mobile", hashset![phone.as_str()])); + } + attrs + } +} diff --git a/src/enterprise/ldap/utils.rs b/src/enterprise/ldap/utils.rs new file mode 100644 index 000000000..319a27df5 --- /dev/null +++ b/src/enterprise/ldap/utils.rs @@ -0,0 +1,73 @@ +use crate::{ + config::DefGuardConfig, + db::{DbPool, User}, + enterprise::ldap::{error::OriLDAPError, LDAPConnection}, +}; + +pub async fn user_from_ldap( + pool: &DbPool, + config: &DefGuardConfig, + username: &str, + password: &str, +) -> Result { + let mut ldap_connection = LDAPConnection::create(config).await?; + let mut user = ldap_connection.get_user(username, password).await?; + let _result = user.save(pool).await; // FIXME: do not ignore errors + Ok(user) +} + +pub async fn ldap_add_user( + config: &DefGuardConfig, + user: &User, + password: &str, +) -> Result<(), OriLDAPError> { + let mut ldap_connection = LDAPConnection::create(config).await?; + match ldap_connection.add_user(user, password).await { + Ok(()) => Ok(()), + // this user might exist in LDAP, just try to set the password + Err(_) => ldap_connection.set_password(&user.username, password).await, + } +} + +pub async fn ldap_modify_user( + config: &DefGuardConfig, + username: &str, + user: &User, +) -> Result<(), OriLDAPError> { + let mut ldap_connection = LDAPConnection::create(config).await?; + ldap_connection.modify_user(username, user).await +} + +pub async fn ldap_delete_user(config: &DefGuardConfig, username: &str) -> Result<(), OriLDAPError> { + let mut ldap_connection = LDAPConnection::create(config).await?; + ldap_connection.delete_user(username).await +} + +pub async fn ldap_add_user_to_group( + config: &DefGuardConfig, + username: &str, + groupname: &str, +) -> Result<(), OriLDAPError> { + let mut ldap_connection = LDAPConnection::create(config).await?; + ldap_connection.add_user_to_group(username, groupname).await +} + +pub async fn ldap_remove_user_from_group( + config: &DefGuardConfig, + username: &str, + groupname: &str, +) -> Result<(), OriLDAPError> { + let mut ldap_connection = LDAPConnection::create(config).await?; + ldap_connection + .remove_user_from_group(username, groupname) + .await +} + +pub async fn ldap_change_password( + config: &DefGuardConfig, + username: &str, + password: &str, +) -> Result<(), OriLDAPError> { + let mut ldap_connection = LDAPConnection::create(config).await?; + ldap_connection.set_password(username, password).await +} diff --git a/src/enterprise/mod.rs b/src/enterprise/mod.rs new file mode 100644 index 000000000..cfcf8437a --- /dev/null +++ b/src/enterprise/mod.rs @@ -0,0 +1,12 @@ +pub mod db; +pub mod grpc; +pub mod handlers; +pub mod ldap; +#[cfg(feature = "oauth")] +pub mod oauth_db; +#[cfg(feature = "oauth")] +pub mod oauth_state; +#[cfg(feature = "openid")] +pub mod openid_idtoken; +#[cfg(feature = "openid")] +pub mod openid_state; diff --git a/src/enterprise/oauth_db.rs b/src/enterprise/oauth_db.rs new file mode 100644 index 000000000..b6f7ab4af --- /dev/null +++ b/src/enterprise/oauth_db.rs @@ -0,0 +1,306 @@ +use crate::{ + auth::{Claims, SESSION_TIMEOUT}, + db::DbPool, +}; +use chrono::{Duration, TimeZone, Utc}; +use oxide_auth::primitives::{ + generator::{RandomGenerator, TagGrant}, + grant::{Extensions, Grant}, + issuer::{IssuedToken, RefreshedToken, TokenType}, +}; +use sqlx::{query, query_as, Error as SqlxError}; + +pub struct OAuth2Client { + /// user ID + pub user: String, + pub client_id: String, + pub client_secret: String, + pub redirect_uri: String, + pub scope: String, +} + +impl OAuth2Client { + /// Find client by ID. + pub async fn find_client_id(pool: &DbPool, client_id: &str) -> Option { + query_as!( + Self, + "SELECT \"user\", client_id, client_secret, redirect_uri, scope \ + FROM oauth2client WHERE client_id = $1", + client_id + ) + .fetch_one(pool) + .await + .ok() + } + + /// Store data in the database. + pub async fn save(&self, pool: &DbPool) -> Result<(), SqlxError> { + query!( + "INSERT INTO oauth2client (\"user\", client_id, client_secret, redirect_uri, scope) \ + VALUES ($1, $2, $3, $4, $5)", + self.user, + self.client_id, + self.client_secret, + self.redirect_uri, + self.scope + ) + .execute(pool) + .await?; + Ok(()) + } +} + +pub struct OAuth2Token { + pub access_token: String, + pub refresh_token: String, + pub redirect_uri: String, + pub scope: String, + pub expires_in: i64, +} + +impl OAuth2Token { + /// Generate new access token, scratching the old one. Changes are reflected in the database. + pub async fn refresh_and_save( + &mut self, + pool: &DbPool, + grant: &Grant, + ) -> Result<(), SqlxError> { + let claims = Claims::new( + grant.owner_id.clone(), + grant.client_id.clone(), + SESSION_TIMEOUT, + ); + let new_access_token = claims.to_jwt().unwrap(); + let mut rnd = RandomGenerator::new(16); + let new_refresh_token = rnd.tag(1, grant).unwrap(); + + query!( + "UPDATE oauth2token SET access_token = $2, refresh_token = $3, expires_in = $4 \ + WHERE access_token = $1", + self.access_token, + new_access_token, + new_refresh_token, + self.expires_in + ) + .execute(pool) + .await?; + Ok(()) + } + + /// Check if token has expired. + #[must_use] + pub fn is_expired(&self) -> bool { + self.expires_in < Utc::now().timestamp() + } + + /// Store data in the database. + pub async fn save(&self, pool: &DbPool) -> Result<(), SqlxError> { + query!( + "INSERT INTO oauth2token (access_token, refresh_token, redirect_uri, scope, expires_in) \ + VALUES ($1, $2, $3, $4, $5)", + self.access_token, + self.refresh_token, + self.redirect_uri, + self.scope, + self.expires_in) + .execute(pool) + .await?; + Ok(()) + } + + /// Delete token from the database. + pub async fn delete(&self, pool: &DbPool) -> Result<(), SqlxError> { + query!( + "DELETE FROM oauth2token WHERE access_token = $1 AND refresh_token = $2", + self.access_token, + self.refresh_token + ) + .execute(pool) + .await?; + Ok(()) + } + + /// Find by access token. + pub async fn find_access_token(pool: &DbPool, access_token: &str) -> Option { + match query_as!( + Self, + "SELECT access_token, refresh_token, redirect_uri, scope, expires_in \ + FROM oauth2token WHERE access_token = $1", + access_token + ) + .fetch_one(pool) + .await + { + Ok(token) => { + if token.is_expired() { + let _result = token.delete(pool).await; + None + } else { + Some(token) + } + } + Err(_) => None, + } + } + + /// Find by refresh token. + pub async fn find_refresh_token(pool: &DbPool, refresh_token: &str) -> Option { + match query_as!( + Self, + "SELECT access_token, refresh_token, redirect_uri, scope, expires_in \ + FROM oauth2token WHERE refresh_token = $1", + refresh_token + ) + .fetch_one(pool) + .await + { + Ok(token) => { + if token.is_expired() { + let _result = token.delete(pool).await; + None + } else { + Some(token) + } + } + Err(_) => None, + } + } +} + +impl From for OAuth2Token { + fn from(grant: Grant) -> Self { + let claims = Claims::new( + grant.owner_id.clone(), + grant.client_id.clone(), + SESSION_TIMEOUT, + ); + let mut rnd = RandomGenerator::new(16); + let refresh_token = rnd.tag(1, &grant).unwrap(); + Self { + access_token: claims.to_jwt().unwrap(), + refresh_token, + redirect_uri: grant.redirect_uri.to_string(), + scope: grant.scope.to_string(), + expires_in: claims.exp as i64, + } + } +} + +impl From for Grant { + fn from(token: OAuth2Token) -> Self { + let claims = Claims::from_jwt(&token.access_token).unwrap(); + Self { + owner_id: claims.sub, + client_id: claims.client_id, + scope: token.scope.parse().unwrap(), + redirect_uri: token.redirect_uri.parse().unwrap(), + until: Utc::now() + Duration::minutes(1), + extensions: Extensions::new(), + } + } +} + +impl From for IssuedToken { + fn from(token: OAuth2Token) -> Self { + Self { + token: token.access_token, + refresh: Some(token.refresh_token), + until: Utc.timestamp(token.expires_in, 0), + token_type: TokenType::Bearer, + } + } +} + +impl From for RefreshedToken { + fn from(token: OAuth2Token) -> Self { + Self { + token: token.access_token, + refresh: Some(token.refresh_token), + until: Utc.timestamp(token.expires_in, 0), + token_type: TokenType::Bearer, + } + } +} + +pub struct AuthorizationCode { + /// user ID + pub user: String, + pub client_id: String, + pub code: String, + pub redirect_uri: String, + pub scope: String, + pub auth_time: i64, +} + +impl AuthorizationCode { + /// Store data in the database. + pub async fn save(&self, pool: &DbPool) -> Result<(), SqlxError> { + query!( + "INSERT INTO authorization_code \ + (\"user\", client_id, code, redirect_uri, scope, auth_time) \ + VALUES ($1, $2, $3, $4, $5, $6)", + self.user, + self.client_id, + self.code, + self.redirect_uri, + self.scope, + self.auth_time + ) + .execute(pool) + .await?; + Ok(()) + } + + /// Delete from the database. + pub async fn delete(&self, pool: &DbPool) -> Result<(), SqlxError> { + query!( + "DELETE FROM authorization_code WHERE client_id = $1 AND code = $2", + self.client_id, + self.code, + ) + .execute(pool) + .await?; + Ok(()) + } + + /// Find by code. + pub async fn find_code(pool: &DbPool, code: &str) -> Option { + query_as!( + Self, + "SELECT \"user\", client_id, code, redirect_uri, scope, auth_time \ + FROM authorization_code WHERE code = $1", + code + ) + .fetch_one(pool) + .await + .ok() + } +} + +impl From for AuthorizationCode { + fn from(grant: Grant) -> Self { + let mut rnd = RandomGenerator::new(16); + let code = rnd.tag(2, &grant).unwrap(); + Self { + user: grant.owner_id, + client_id: grant.client_id, + code, + redirect_uri: grant.redirect_uri.to_string(), + scope: grant.scope.to_string(), + auth_time: Utc::now().timestamp(), + } + } +} + +impl From for Grant { + fn from(code: AuthorizationCode) -> Self { + Self { + owner_id: code.user, + client_id: code.client_id, + scope: code.scope.parse().unwrap(), + redirect_uri: code.redirect_uri.parse().unwrap(), + until: Utc::now() + Duration::minutes(1), + extensions: Extensions::new(), + } + } +} diff --git a/src/enterprise/oauth_state.rs b/src/enterprise/oauth_state.rs new file mode 100644 index 000000000..3a017c6e8 --- /dev/null +++ b/src/enterprise/oauth_state.rs @@ -0,0 +1,332 @@ +use crate::{ + db::DbPool, + enterprise::oauth_db::{AuthorizationCode, OAuth2Client, OAuth2Token}, + oxide_auth_rocket::{OAuthFailure, OAuthRequest, OAuthResponse, WebError}, +}; +use oxide_auth::{ + code_grant::{ + accesstoken::Request as TokenRequest, authorization::Request as AuthRequest, + extensions::Pkce, + }, + endpoint::{OAuthError, OwnerConsent, QueryParameter, Scopes, Solicitation, Template}, + frontends::simple::extensions::{AccessTokenAddon, AddonResult, AuthorizationAddon}, + primitives::{ + grant::{Extensions, Grant}, + issuer::{IssuedToken, RefreshedToken}, + registrar::{BoundClient, ClientUrl, ExactUrl, PreGrant, RegisteredUrl, RegistrarError}, + scope::Scope, + }, +}; +use oxide_auth_async::{ + code_grant::{ + access_token::Extension as AccessTokenExtension, + authorization::Extension as AuthorizationExtension, + }, + endpoint::{Endpoint, Extension, OwnerSolicitor}, + primitives::{Authorizer, Issuer, Registrar}, +}; +use rocket::{http, Response}; +use std::borrow::Cow; + +// Must implement Clone for flows. +#[derive(Clone)] +pub struct OAuthState { + pool: DbPool, + pub decision: bool, + pub allow: bool, +} + +impl OAuthState { + pub async fn new(pool: DbPool) -> Self { + // FIXME: Hard-coded client. It should be removed once client management has been implemented. + let client = OAuth2Client { + user: "dummy".into(), + client_id: "LocalClient".into(), + client_secret: "secret".into(), + redirect_uri: "http://localhost:3000/".into(), + scope: "default-scope".into(), + }; + // FIXME: use result + let _result = client.save(&pool).await; + + OAuthState { + pool, + decision: false, + allow: false, + } + } +} + +#[async_trait] +impl Authorizer for OAuthState { + /// Create a code which allows retrieval of a bearer token at a later time. + async fn authorize(&mut self, grant: Grant) -> Result { + let auth_code: AuthorizationCode = grant.into(); + auth_code.save(&self.pool).await.unwrap(); + Ok(auth_code.code) + } + + /// Retrieve the parameters associated with a token, invalidating the code + /// in the process. In particular, a code should not be usable twice + /// (there is no stateless implementation of an authorizer for this reason). + async fn extract(&mut self, code: &str) -> Result, ()> { + match AuthorizationCode::find_code(&self.pool, code).await { + Some(auth_code) => { + let _result = auth_code.delete(&self.pool).await; + Ok(Some(auth_code.into())) + } + None => Err(()), + } + } +} + +#[async_trait] +impl Issuer for OAuthState { + /// Create a token authorizing the request parameters. + async fn issue(&mut self, grant: Grant) -> Result { + let token = OAuth2Token::from(grant); + token.save(&self.pool).await.map_err(|_| ())?; + Ok(token.into()) + } + + /// Refresh a token. + async fn refresh(&mut self, refresh_token: &str, grant: Grant) -> Result { + match OAuth2Token::find_refresh_token(&self.pool, refresh_token).await { + Some(mut token) => { + token + .refresh_and_save(&self.pool, &grant) + .await + .map_err(|_| ())?; + Ok(token.into()) + } + None => Err(()), + } + } + + /// Get the values corresponding to a bearer token. + async fn recover_token(&mut self, access_token: &str) -> Result, ()> { + match OAuth2Token::find_access_token(&self.pool, access_token).await { + Some(token) => Ok(Some(token.into())), + None => Err(()), + } + } + + /// Get the values corresponding to a refresh token. + async fn recover_refresh(&mut self, refresh_token: &str) -> Result, ()> { + match OAuth2Token::find_refresh_token(&self.pool, refresh_token).await { + Some(token) => Ok(Some(token.into())), + None => Err(()), + } + } +} + +#[async_trait] +impl<'r> OwnerSolicitor> for OAuthState { + async fn check_consent( + &mut self, + req: &mut OAuthRequest, + solicitation: Solicitation<'_>, + ) -> OwnerConsent> { + if self.decision { + consent_decision(self.allow, &solicitation) + } else { + consent_form(req, &solicitation) + } + } +} + +#[async_trait] +impl Registrar for OAuthState { + /// Determine the allowed scope and redirection url for the client. + async fn bound_redirect<'a>( + &self, + bound: ClientUrl<'a>, + ) -> Result, RegistrarError> { + if let Some(client) = OAuth2Client::find_client_id(&self.pool, &bound.client_id).await { + if let Ok(client_uri) = ExactUrl::new(client.redirect_uri) { + if let Some(url) = bound.redirect_uri { + if url.as_ref() == &client_uri { + return Ok(BoundClient { + client_id: bound.client_id, + redirect_uri: Cow::Owned(RegisteredUrl::from(client_uri)), + }); + } + } + } + } + Err(RegistrarError::Unspecified) + } + + /// Finish the negotiations with the registrar. + /// Always overrides the scope with a default scope. + async fn negotiate<'a>( + &self, + bound: BoundClient<'a>, + _scope: Option, + ) -> Result { + match OAuth2Client::find_client_id(&self.pool, &bound.client_id).await { + Some(client) => Ok(PreGrant { + client_id: bound.client_id.into_owned(), + redirect_uri: bound.redirect_uri.into_owned(), + scope: client.scope.parse().unwrap(), + }), + None => Err(RegistrarError::Unspecified), + } + } + + /// Try to login as client with some authentication. + /// Currently, public clients (without passphrase) are forbidden. + async fn check( + &self, + client_id: &str, + passphrase: Option<&[u8]>, + ) -> Result<(), RegistrarError> { + if let Some(secret) = passphrase { + if let Some(client) = OAuth2Client::find_client_id(&self.pool, client_id).await { + if secret == client.client_secret.as_bytes() { + return Ok(()); + } + } + } + Err(RegistrarError::Unspecified) + } +} + +impl Extension for OAuthState { + fn authorization(&mut self) -> Option<&mut (dyn AuthorizationExtension + Send)> { + Some(self) + } + + fn access_token(&mut self) -> Option<&mut (dyn AccessTokenExtension + Send)> { + Some(self) + } +} + +#[async_trait] +impl AccessTokenExtension for OAuthState { + async fn extend( + &mut self, + request: &(dyn TokenRequest + Sync), + mut data: Extensions, + ) -> Result { + let mut result_data = Extensions::new(); + let ext = Pkce::optional(); + let ext_data = data.remove(&ext); + + match AccessTokenAddon::execute(&ext, request, ext_data) { + AddonResult::Ok => (), + AddonResult::Data(data) => result_data.set(&ext, data), + AddonResult::Err => return Err(()), + } + + Ok(result_data) + } +} + +#[async_trait] +impl AuthorizationExtension for OAuthState { + async fn extend(&mut self, request: &(dyn AuthRequest + Sync)) -> Result { + let mut result_data = Extensions::new(); + let ext = Pkce::optional(); + + match AuthorizationAddon::execute(&ext, request) { + AddonResult::Ok => (), + AddonResult::Data(data) => result_data.set(&ext, data), + AddonResult::Err => return Err(()), + } + + Ok(result_data) + } +} + +impl<'r> Endpoint> for OAuthState { + type Error = OAuthFailure; + + fn registrar(&self) -> Option<&(dyn Registrar + Sync)> { + Some(self) + } + + fn authorizer_mut(&mut self) -> Option<&mut (dyn Authorizer + Send)> { + Some(self) + } + + fn issuer_mut(&mut self) -> Option<&mut (dyn Issuer + Send)> { + Some(self) + } + + fn owner_solicitor(&mut self) -> Option<&mut (dyn OwnerSolicitor> + Send)> { + Some(self) + } + + fn scopes(&mut self) -> Option<&mut dyn Scopes>> { + None + } + + fn response( + &mut self, + _request: &mut OAuthRequest<'r>, + _kind: Template, + ) -> Result, Self::Error> { + Ok(OAuthResponse::new()) + } + + fn error(&mut self, err: OAuthError) -> Self::Error { + err.into() + } + + fn web_error(&mut self, err: WebError) -> Self::Error { + err.into() + } + + fn extension(&mut self) -> Option<&mut (dyn Extension + Send)> { + Some(self) + } +} + +fn consent_form<'r>( + req: &mut OAuthRequest, + solicitation: &Solicitation<'_>, +) -> OwnerConsent> { + let query = req.query.as_ref().unwrap(); + let code_challenge = query + .unique_value("code_challenge") + .unwrap_or(Cow::Borrowed("")) + .to_string(); + let code_challenge_method = query + .unique_value("code_challenge_method") + .unwrap_or(Cow::Borrowed("")) + .to_string(); + + let grant = solicitation.pre_grant(); + let state = solicitation.state(); + let scope = grant.scope.to_string(); + let mut extra = vec![ + ("response_type", "code"), + ("client_id", grant.client_id.as_str()), + ("redirect_uri", grant.redirect_uri.as_str()), + ("scope", &scope), + ("code_challenge", &code_challenge), + ("code_challenge_method", &code_challenge_method), + ]; + if let Some(state) = state { + extra.push(("state", state)); + } + + let location = format!("/consent?{}", serde_urlencoded::to_string(extra).unwrap()); + OwnerConsent::InProgress( + Response::build() + .status(http::Status::Found) + .header(http::Header::new("Location", location)) + .finalize() + .into(), + ) +} + +fn consent_decision<'r>(allowed: bool, _: &Solicitation) -> OwnerConsent> { + if allowed { + // FIXME: get rid of the dummy + OwnerConsent::Authorized("dummy".into()) + } else { + OwnerConsent::Denied + } +} diff --git a/src/enterprise/openid_idtoken.rs b/src/enterprise/openid_idtoken.rs new file mode 100644 index 000000000..7b300236f --- /dev/null +++ b/src/enterprise/openid_idtoken.rs @@ -0,0 +1,108 @@ +use crate::{ + auth::{JWT_ISSUER, SESSION_TIMEOUT}, + db::User, +}; +use jsonwebtoken::{encode, errors::Error as JWTError, EncodingKey, Header}; +use std::time::{Duration, SystemTime}; + +// ID Token claims: https://openid.net/specs/openid-connect-core-1_0.html#IDToken +#[derive(Deserialize, Serialize)] +pub struct IDTokenClaims { + pub iss: String, + // User id + pub sub: String, + // Client id + pub aud: String, + pub exp: u64, + pub iat: u64, + pub given_name: Option, + pub family_name: Option, + pub email: Option, + pub email_verified: Option, + pub phone: Option, + pub phone_verified: Option, + pub nonce: Option, +} + +// Supported user claims +pub struct UserClaims { + pub username: String, + pub given_name: Option, + pub family_name: Option, + pub email: Option, + pub email_verified: Option, + pub phone: Option, + pub phone_verified: Option, +} + +impl IDTokenClaims { + #[must_use] + pub fn new(sub: String, aud: String, nonce: Option, user_claims: UserClaims) -> Self { + let now = SystemTime::now(); + let exp = now + .checked_add(Duration::from_secs(SESSION_TIMEOUT)) + .expect("valid time") + .duration_since(SystemTime::UNIX_EPOCH) + .expect("valid timestamp") + .as_secs(); + let iat = now + .duration_since(SystemTime::UNIX_EPOCH) + .expect("valid timestamp") + .as_secs(); + Self { + iss: JWT_ISSUER.to_owned(), + sub, + aud, + exp, + iat, + nonce, + given_name: user_claims.given_name, + family_name: user_claims.family_name, + email: user_claims.email, + email_verified: user_claims.email_verified, + phone: user_claims.phone, + phone_verified: user_claims.phone_verified, + } + } + + /// Convert claims to JWT. + pub fn to_jwt(&self, client_secret: &str) -> Result { + encode( + &Header::default(), + self, + &EncodingKey::from_secret(client_secret.as_bytes()), + ) + } + // Get user data based on scopes: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + // FIXME: must be better way to do this + pub fn get_user_claims(user: User, scopes: &str) -> UserClaims { + let mut user_claims = UserClaims { + username: user.username, + given_name: Some(user.first_name), + family_name: Some(user.last_name), + email: Some(user.email.clone()), + email_verified: Some(true), + phone: user.phone.clone(), + phone_verified: Some(true), + }; + if user.email.is_empty() { + user_claims.email_verified = Some(false); + } + if user.phone.is_none() { + user_claims.phone_verified = Some(false); + } + if !scopes.contains("email") { + user_claims.email = None; + user_claims.email_verified = None; + } + if !scopes.contains("profile") { + user_claims.given_name = None; + user_claims.family_name = None; + } + if !scopes.contains("phone") { + user_claims.phone = None; + user_claims.phone_verified = None; + } + user_claims + } +} diff --git a/src/enterprise/openid_state.rs b/src/enterprise/openid_state.rs new file mode 100644 index 000000000..854902744 --- /dev/null +++ b/src/enterprise/openid_state.rs @@ -0,0 +1,145 @@ +use crate::{ + db::DbPool, + enterprise::db::openid::{AuthorizedApp, OpenIDClient, OpenIDClientAuth}, +}; +use chrono::Local; +use openidconnect::PkceCodeChallenge; +use rocket::{response::Redirect, FromForm}; + +#[derive(FromForm, Deserialize)] +pub struct OpenIDRequest { + pub client_id: String, + pub scope: String, + pub redirect_uri: String, + pub response_type: String, + pub state: String, + pub nonce: Option, + pub allow: bool, +} + +impl OpenIDRequest { + pub fn verify_response(&self) -> Result<(), Redirect> { + let response: Vec<&str> = self.response_type.split(' ').collect(); + if response.len() > 1 || !response.contains(&"code") { + Err(Redirect::found(format!( + "{}?error=unsupported_response_type", + self.redirect_uri + ))) + } else { + Ok(()) + } + } + + // verify redirect uri + pub fn verify_redirect_uri(&self, client: &OpenIDClient) -> Result<(), Redirect> { + if self.redirect_uri != client.redirect_uri { + Err(Redirect::found(format!( + "{}?error=unauthorized_client&error_description=client_redirect_uri_dont_match", + self.redirect_uri + ))) + } else { + Ok(()) + } + } + + /// Verify user allow + pub fn verify_allow(&self) -> Result<(), Redirect> { + if self.allow { + Ok(()) + } else { + Err(Redirect::found(format!( + "{}?error=user_unauthorized", + self.redirect_uri + ))) + } + } + + /// Verify if supported scopes + pub fn verify_scope(&self) -> Result<(), Redirect> { + if self.scope.to_lowercase().contains("openid") { + Ok(()) + } else { + Err(Redirect::found(format!( + "{}?error=wrong_scope&error_description=scope_must_contain_openid", + self.redirect_uri + ))) + } + } + + // Create authorization code and save it to database + pub async fn create_code( + &self, + pool: &DbPool, + username: &str, + user_id: i64, + ) -> Result { + match OpenIDClient::find_enabled_for_client_id(pool, &self.client_id).await { + Ok(Some(client)) => { + self.verify_allow()?; + self.verify_scope()?; + self.verify_response()?; + self.verify_redirect_uri(&client)?; + let (code, _) = PkceCodeChallenge::new_random_sha256_len(32); + let mut client_auth = OpenIDClientAuth::new( + username.into(), + code.as_str().into(), + client.client_id.clone(), + self.state.clone(), + client.redirect_uri.clone(), + self.scope.clone(), + self.nonce.clone(), + ); + + match AuthorizedApp::find_by_user_and_client_id(pool, user_id, &client.client_id) + .await + { + Ok(Some(_app)) => (), + Ok(None) => { + let date = Local::now().format("%d-%m-%Y %H:%M"); + let mut app = AuthorizedApp::new( + user_id, + client.client_id.clone(), + client.home_url, + date.to_string(), + client.name, + ); + app.save(pool).await.map_err(|_| { + Redirect::found(format!( + "{}?error=failed_to_save_app", + client.redirect_uri + )) + })?; + } + Err(err) => { + return Err(Redirect::found(format!( + "{}?error=internal_server_error&error_description={}", + self.redirect_uri, err + ))) + } + }; + + client_auth.save(pool).await.map_err(|_| { + Redirect::found(format!( + "{}?error=failed_to_save_authorization_code", + client.redirect_uri + )) + })?; + info!("Created code for client: {}", client.client_id); + Ok(Redirect::found(format!( + "{}?code={}&state={}", + client.redirect_uri, + code.as_str(), + self.state + ))) + } + Ok(None) => Ok(Redirect::found(format!( + "{}error=unauthorized_client&error_description=client_id_not_found", + self.redirect_uri + ))), + Err(err) => Err(Redirect::found(format!( + "{}error=internal_server_error&error_description={}", + self.redirect_uri, err + ))), + } + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 000000000..3c36d5aaf --- /dev/null +++ b/src/error.rs @@ -0,0 +1,71 @@ +use crate::{db::models::error::ModelError, enterprise::ldap::error::OriLDAPError}; +use sqlx::error::Error as SqlxError; +use std::{error::Error, fmt}; + +#[derive(Debug)] +pub enum OriWebError { + Grpc(String), + Ldap(String), + IncorrectUsername(String), + ObjectNotFound(String), + Serialization(String), + Authorization(String), + Forbidden(String), + DbError(String), + ModelError(String), + Http(rocket::http::Status), +} + +impl fmt::Display for OriWebError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OriWebError::Grpc(msg) => write!(f, "GRPC error: {}", msg), + OriWebError::Ldap(msg) => write!(f, "LDAP error: {}", msg), + OriWebError::IncorrectUsername(username) => { + write!(f, "Incorrect username: {}", username) + } + OriWebError::ObjectNotFound(msg) => write!(f, "Object not found: {}", msg), + OriWebError::Serialization(msg) => write!(f, "Serialization error: {}", msg), + OriWebError::Authorization(msg) => write!(f, "Authorization error: {}", msg), + OriWebError::Forbidden(msg) => write!(f, "Forbidden error: {}", msg), + OriWebError::DbError(msg) => write!(f, "Database error: {}", msg), + OriWebError::ModelError(msg) => write!(f, "Model error: {}", msg), + OriWebError::Http(status) => write!(f, "HTTP error: {}", status), + } + } +} + +impl Error for OriWebError {} + +impl From for OriWebError { + fn from(status: tonic::Status) -> Self { + Self::Grpc(status.message().into()) + } +} + +impl From for OriWebError { + fn from(status: rocket::http::Status) -> Self { + Self::Http(status) + } +} + +impl From for OriWebError { + fn from(error: OriLDAPError) -> Self { + match error { + OriLDAPError::ObjectNotFound(msg) => Self::ObjectNotFound(msg), + OriLDAPError::Ldap(msg) => Self::Ldap(msg), + } + } +} + +impl From for OriWebError { + fn from(error: SqlxError) -> Self { + Self::DbError(error.to_string()) + } +} + +impl From for OriWebError { + fn from(error: ModelError) -> Self { + Self::ModelError(error.to_string()) + } +} diff --git a/src/grpc/auth.rs b/src/grpc/auth.rs new file mode 100644 index 000000000..865744380 --- /dev/null +++ b/src/grpc/auth.rs @@ -0,0 +1,50 @@ +use crate::{ + auth::{Claims, SESSION_TIMEOUT}, + db::{DbPool, User}, +}; +use jsonwebtoken::errors::Error as JWTError; +use tonic::{Request, Response, Status}; + +tonic::include_proto!("auth"); + +pub struct AuthServer { + pool: DbPool, +} + +impl AuthServer { + #[must_use] + pub fn new(pool: DbPool) -> Self { + Self { pool } + } + + /// Creates JWT token for specified user + fn create_jwt(uid: &str) -> Result { + Claims::new(uid.into(), String::new(), SESSION_TIMEOUT).to_jwt() + } +} + +#[tonic::async_trait] +impl auth_service_server::AuthService for AuthServer { + /// Authentication gRPC service. Verifies provided username and password + /// agains LDAP and returns JWT token if correct. + async fn authenticate( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + debug!("Authenticating user {}", &request.username); + match User::find_by_username(&self.pool, &request.username).await { + Ok(Some(user)) => match user.verify_password(&request.password) { + Ok(_) => { + info!("Authentication successful for user {}", &request.username); + Ok(Response::new(AuthenticateResponse { + token: Self::create_jwt(&request.username) + .map_err(|_| Status::unauthenticated("error creating JWT token"))?, + })) + } + Err(_) => Err(Status::unauthenticated("invalid credentials")), + }, + _ => Err(Status::unauthenticated("user not found")), + } + } +} diff --git a/src/grpc/gateway.rs b/src/grpc/gateway.rs new file mode 100644 index 000000000..e36374441 --- /dev/null +++ b/src/grpc/gateway.rs @@ -0,0 +1,278 @@ +use crate::db::{ + models::wireguard::{WireguardNetwork, WireguardPeerStats}, + DbPool, Device, GatewayEvent, +}; +use chrono::{NaiveDateTime, Utc}; +use std::sync::Arc; +use tokio::sync::{ + mpsc::{self, UnboundedReceiver}, + Mutex, +}; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{Request, Response, Status}; + +tonic::include_proto!("gateway"); + +pub struct GatewayServer { + pool: DbPool, + wireguard_rx: Arc>>, +} + +impl GatewayServer { + #[must_use] + pub fn new(wireguard_rx: UnboundedReceiver, pool: DbPool) -> Self { + Self { + wireguard_rx: Arc::new(Mutex::new(wireguard_rx)), + pool, + } + } + + async fn send_network_update( + tx: &mpsc::Sender>, + network: &WireguardNetwork, + update_type: i32, + ) -> Result<(), Status> { + if let Err(err) = tx + .send(Ok(Update { + update_type, + update: Some(update::Update::Network(Configuration { + name: network.name.clone(), + prvkey: network.prvkey.clone(), + address: network.address.to_string(), + port: network.port as u32, + peers: Vec::new(), + })), + })) + .await + { + let msg = format!( + "Failed to send network update, network {}, update type: {}, error: {}", + network.name, update_type, err, + ); + error!("{}", msg); + return Err(Status::new(tonic::Code::Internal, msg)); + } + Ok(()) + } + + async fn send_network_delete( + tx: &mpsc::Sender>, + network_name: &str, + ) -> Result<(), Status> { + if let Err(err) = tx + .send(Ok(Update { + update_type: 2, + update: Some(update::Update::Network(Configuration { + name: network_name.to_string(), + prvkey: String::new(), + address: String::new(), + port: 0, + peers: Vec::new(), + })), + })) + .await + { + let msg = format!( + "Failed to send network update, network {}, update type: {}, error: {}", + network_name, 2, err, + ); + error!("{}", msg); + return Err(Status::new(tonic::Code::Internal, msg)); + } + Ok(()) + } + + async fn send_peer_update( + tx: &mpsc::Sender>, + device: &Device, + update_type: i32, + ) -> Result<(), Status> { + if let Err(err) = tx + .send(Ok(Update { + update_type, + update: Some(update::Update::Peer(Peer { + pubkey: device.wireguard_pubkey.clone(), + allowed_ips: vec![device.wireguard_ip.clone()], + })), + })) + .await + { + let msg = format!( + "Failed to send network update, network {}, update type: {}, error: {}", + device.name, update_type, err, + ); + error!("{}", msg); + return Err(Status::new(tonic::Code::Internal, msg)); + } + Ok(()) + } + + async fn send_peer_delete( + tx: &mpsc::Sender>, + peer_pubkey: &str, + ) -> Result<(), Status> { + if let Err(err) = tx + .send(Ok(Update { + update_type: 2, + update: Some(update::Update::Peer(Peer { + pubkey: peer_pubkey.into(), + allowed_ips: Vec::new(), + })), + })) + .await + { + let msg = format!( + "Failed to send peer update, peer {}, update type: 2, error: {}", + peer_pubkey, err, + ); + error!("{}", msg); + return Err(Status::new(tonic::Code::Internal, msg)); + } + Ok(()) + } +} + +fn gen_config(network: &WireguardNetwork, devices: &[Device]) -> Configuration { + let peers = devices + .iter() + .map(|d| Peer { + pubkey: d.wireguard_pubkey.clone(), + allowed_ips: vec![d.wireguard_ip.clone()], + }) + .collect(); + + Configuration { + name: network.name.clone(), + port: network.port as u32, + prvkey: network.prvkey.clone(), + address: network.address.to_string(), + peers, + } +} + +impl From for WireguardPeerStats { + fn from(stats: PeerStats) -> Self { + let endpoint = match stats.endpoint { + endpoint if endpoint.is_empty() => None, + _ => Some(stats.endpoint), + }; + Self { + id: None, + // FIXME: hard-coded network id + network: 1, + endpoint, + device_id: -1, + collected_at: Utc::now().naive_utc(), + upload: stats.upload, + download: stats.download, + latest_handshake: NaiveDateTime::from_timestamp(stats.latest_handshake, 0), + allowed_ips: Some(stats.allowed_ips), + } + } +} + +#[tonic::async_trait] +impl gateway_service_server::GatewayService for GatewayServer { + type UpdatesStream = ReceiverStream>; + + async fn stats( + &self, + request: Request>, + ) -> Result, Status> { + let mut stream = request.into_inner(); + while let Some(peer_stats) = stream.message().await? { + let public_key = peer_stats.public_key.clone(); + let mut stats = WireguardPeerStats::from(peer_stats); + // Get device by public key and fill in stats.device_id + stats.device_id = match Device::find_by_pubkey(&self.pool, &public_key).await { + Ok(Some(device)) => device + .id + .ok_or_else(|| Status::new(tonic::Code::Internal, "Device has no id"))?, + Ok(None) => { + error!("Device with public key {} not found", &public_key); + return Err(Status::new( + tonic::Code::Internal, + format!("Device with public key {} not found", &public_key), + )); + } + Err(err) => { + error!( + "Failed to retrieve device with public key {}: {}", + &public_key, err + ); + return Err(Status::new( + tonic::Code::Internal, + format!( + "Failed to retrieve device with public key {}: {}", + &public_key, err + ), + )); + } + }; + // Save stats to db + if let Err(err) = stats.save(&self.pool).await { + error!("Saving WireGuard peer stats to db failed: {}", err); + return Err(Status::new( + tonic::Code::Internal, + format!("Saving WireGuard peer stats to db failed: {}", err), + )); + } + debug!("Saved WireGuard peer stats to db: {:?}", stats); + } + Ok(Response::new(())) + } + + async fn config(&self, _request: Request<()>) -> Result, Status> { + let pool = self.pool.clone(); + let mut network = WireguardNetwork::find_by_id(&pool, 1) + .await + .map_err(|e| { + Status::new( + tonic::Code::FailedPrecondition, + format!("Failed to retrieve network: {}", e), + ) + })? + .ok_or_else(|| Status::new(tonic::Code::FailedPrecondition, "Network not found"))?; + network.connected_at = Some(Utc::now().naive_utc()); + if let Err(err) = network.save(&pool).await { + error!("Failed to save network: {}", err); + } + let devices = Device::all(&pool).await.unwrap_or_default(); + Ok(Response::new(gen_config(&network, &devices))) + } + + async fn updates(&self, _: Request<()>) -> Result, Status> { + let (tx, rx) = mpsc::channel(4); + let events_rx = Arc::clone(&self.wireguard_rx); + tokio::spawn(async move { + while let Some(update) = events_rx.lock().await.recv().await { + let result = match update { + GatewayEvent::NetworkCreated(network) => { + Self::send_network_update(&tx, &network, 0).await + } + GatewayEvent::NetworkModified(network) => { + Self::send_network_update(&tx, &network, 1).await + } + GatewayEvent::NetworkDeleted(network_name) => { + Self::send_network_delete(&tx, &network_name).await + } + GatewayEvent::DeviceCreated(device) => { + Self::send_peer_update(&tx, &device, 0).await + } + GatewayEvent::DeviceModified(device) => { + Self::send_peer_update(&tx, &device, 1).await + } + GatewayEvent::DeviceDeleted(device_name) => { + Self::send_peer_delete(&tx, &device_name).await + } + }; + if let Err(err) = result { + error!("Client stream disconnected: {}", err); + break; + } + } + }); + + Ok(Response::new(ReceiverStream::new(rx))) + } +} diff --git a/src/grpc/interceptor.rs b/src/grpc/interceptor.rs new file mode 100644 index 000000000..a7d7e6719 --- /dev/null +++ b/src/grpc/interceptor.rs @@ -0,0 +1,27 @@ +use crate::auth::Claims; +use tonic::{Request, Status}; + +/// Auth interceptor used by GRPC services. Verifies JWT token sent +/// in GRPC metadata under "authorization" key. +pub fn jwt_auth_interceptor(mut req: Request<()>) -> Result, Status> { + let token = match req.metadata().get("authorization") { + Some(token) => token + .to_str() + .map_err(|_| Status::unauthenticated("Invalid token"))?, + None => return Err(Status::unauthenticated("Missing authorization header")), + }; + if let Ok(claims) = Claims::from_jwt(token) { + // FIXME: can we push whole Claims object into metadata? + req.metadata_mut().insert( + "username", + claims + .sub + .parse() + .map_err(|_| Status::unknown("Username parsing error"))?, + ); + debug!("Authorization successful for user {}", claims.sub); + Ok(req) + } else { + Err(Status::unauthenticated("Invalid token")) + } +} diff --git a/src/grpc/mod.rs b/src/grpc/mod.rs new file mode 100644 index 000000000..0a126823f --- /dev/null +++ b/src/grpc/mod.rs @@ -0,0 +1,61 @@ +#[cfg(feature = "worker")] +use crate::enterprise::grpc::worker::{ + token_interceptor, worker_service_server::WorkerServiceServer, WorkerServer, +}; +use crate::{ + db::{DbPool, GatewayEvent}, + enterprise::grpc::WorkerState, +}; +use auth::{auth_service_server::AuthServiceServer, AuthServer}; +#[cfg(feature = "wireguard")] +use gateway::{gateway_service_server::GatewayServiceServer, GatewayServer}; +use interceptor::jwt_auth_interceptor; +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::{Arc, Mutex}, +}; +use tokio::sync::mpsc::UnboundedReceiver; +use tonic::transport::{Identity, Server, ServerTlsConfig}; +mod auth; +#[cfg(feature = "wireguard")] +mod gateway; +mod interceptor; + +/// Runs gRPC server with core services. +pub async fn run_grpc_server( + grpc_port: u16, + worker_state: Arc>, + wireguard_rx: UnboundedReceiver, + pool: DbPool, + grpc_cert: Option, + grpc_key: Option, +) -> Result<(), Box> { + // Build gRPC services + let auth_service = AuthServiceServer::new(AuthServer::new(pool.clone())); + #[cfg(feature = "worker")] + let worker_service = WorkerServiceServer::with_interceptor( + WorkerServer::new(pool.clone(), worker_state), + token_interceptor, + ); + #[cfg(feature = "wireguard")] + let gateway_service = GatewayServiceServer::with_interceptor( + GatewayServer::new(wireguard_rx, pool), + jwt_auth_interceptor, + ); + // Run gRPC server + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), grpc_port); + info!("Started gRPC services"); + let mut builder = if let (Some(cert), Some(key)) = (grpc_cert, grpc_key) { + let identity = Identity::from_pem(&cert, &key); + Server::builder().tls_config(ServerTlsConfig::new().identity(identity))? + } else { + Server::builder() + }; + let router = builder.add_service(auth_service); + #[cfg(feature = "wireguard")] + let router = router.add_service(gateway_service); + #[cfg(feature = "worker")] + let router = router.add_service(worker_service); + router.serve(addr).await?; + Ok(()) +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs new file mode 100644 index 000000000..a86cfdb89 --- /dev/null +++ b/src/handlers/auth.rs @@ -0,0 +1,300 @@ +use super::{ + ApiResponse, ApiResult, Auth, AuthCode, AuthTotp, WalletSignature, WebAuthnRegistration, +}; +use crate::{ + appstate::AppState, + auth::SessionInfo, + db::{Session, SessionState, Settings, User, Wallet, WebAuthn}, + enterprise::ldap::utils::user_from_ldap, + error::OriWebError, + license::Features, +}; +use rocket::{ + http::{Cookie, CookieJar, Status}, + serde::json::{serde_json::json, Json}, + State, +}; +use sqlx::types::Uuid; +use webauthn_rs::prelude::PublicKeyCredential; + +/// For successful login, return: +/// * 200 with MFA disabled +/// * 201 with MFA enabled when additional authentication factor is required +#[post("/auth", format = "json", data = "")] +pub async fn authenticate( + appstate: &State, + data: Json, + cookies: &CookieJar<'_>, +) -> ApiResult { + debug!("Authenticating user {}", data.username); + let user = match User::find_by_username(&appstate.pool, &data.username).await { + Ok(Some(user)) => match user.verify_password(&data.password) { + Ok(_) => user, + Err(err) => { + info!("Failed to authenticate user {}: {}", data.username, err); + return Err(OriWebError::Authorization(err.to_string())); + } + }, + Ok(None) => { + error!("User not found {}", data.username); + // create user from LDAP + if appstate.license.validate(&Features::Ldap) { + if let Ok(user) = user_from_ldap( + &appstate.pool, + &appstate.config, + &data.username, + &data.password, + ) + .await + { + user + } else { + return Err(OriWebError::Authorization("user not found".into())); + } + } else { + return Err(OriWebError::Authorization("LDAP feature disabled".into())); + } + } + Err(err) => { + error!( + "Error when trying to authenticate user {}: {}", + data.username, err + ); + return Err(OriWebError::DbError(err.to_string())); + } + }; + + Session::delete_expired(&appstate.pool).await?; + let session = Session::new(user.id.unwrap(), SessionState::PasswordVerified); + session.save(&appstate.pool).await?; + cookies.add(Cookie::new("session", session.id)); + + info!("Authenticated user {}", data.username); + if user.mfa_enabled { + Ok(ApiResponse { + json: json!({}), + status: Status::Created, + }) + } else { + Ok(ApiResponse::default()) + } +} + +#[post("/auth/logout")] +pub fn logout(cookies: &CookieJar<'_>) -> ApiResult { + cookies.remove(Cookie::named("session")); + Ok(ApiResponse::default()) +} + +/// Enable MFA +#[post("/auth/mfa")] +pub async fn mfa_enable(session_info: SessionInfo, appstate: &State) -> ApiResult { + let mut user = session_info.user; + user.enable_mfa(&appstate.pool).await?; + Ok(ApiResponse::default()) +} + +/// Disable MFA +#[delete("/auth/mfa")] +pub async fn mfa_disable(session_info: SessionInfo, appstate: &State) -> ApiResult { + let mut user = session_info.user; + user.disable_mfa(&appstate.pool).await?; + Ok(ApiResponse::default()) +} + +/// Initialize WebAuthn registration +#[post("/auth/webauthn/init")] +pub async fn webauthn_init(mut session: Session, appstate: &State) -> ApiResult { + if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { + match appstate.webauthn.start_passkey_registration( + Uuid::new_v4(), + &user.email, + &user.username, + None, + ) { + Ok((ccr, passkey_reg)) => { + session + .set_passkey_registration(&appstate.pool, &passkey_reg) + .await?; + Ok(ApiResponse { + json: json!(ccr), + status: Status::Ok, + }) + } + Err(_err) => Err(OriWebError::Http(Status::BadRequest)), + } + } else { + Err(OriWebError::ObjectNotFound("invalid user".into())) + } +} + +/// Finish WebAuthn registration +#[post("/auth/webauthn/finish", format = "json", data = "")] +pub async fn webauthn_finish( + session: Session, + appstate: &State, + data: Json, +) -> ApiResult { + if let Some(passkey_reg) = session.get_passkey_registration() { + let webauth_reg = data.into_inner(); + if let Ok(passkey) = appstate + .webauthn + .finish_passkey_registration(&webauth_reg.rpkc, &passkey_reg) + { + let mut webauthn = WebAuthn::new(session.user_id, webauth_reg.name, &passkey)?; + webauthn.save(&appstate.pool).await?; + return Ok(ApiResponse::default()); + } + } + Err(OriWebError::Http(Status::BadRequest)) +} + +/// Start WebAuthn authentication +#[post("/auth/webauthn/start")] +pub async fn webauthn_start(mut session: Session, appstate: &State) -> ApiResult { + let passkeys = WebAuthn::passkeys_for_user(&appstate.pool, session.user_id).await?; + + match appstate.webauthn.start_passkey_authentication(&passkeys) { + Ok((rcr, passkey_reg)) => { + session + .set_passkey_authentication(&appstate.pool, &passkey_reg) + .await?; + Ok(ApiResponse { + json: json!(rcr), + status: Status::Ok, + }) + } + Err(_err) => Err(OriWebError::Http(Status::BadRequest)), + } +} + +/// Finish WebAuthn authentication +#[post("/auth/webauthn", format = "json", data = "")] +pub async fn webauthn_end( + mut session: Session, + appstate: &State, + pubkey: Json, +) -> ApiResult { + if let Some(passkey_auth) = session.get_passkey_authentication() { + if let Ok(auth_result) = appstate + .webauthn + .finish_passkey_authentication(&pubkey, &passkey_auth) + { + if auth_result.needs_update() { + // Find `Passkey` and try to update its credentials + for mut webauthn in WebAuthn::all_for_user(&appstate.pool, session.user_id).await? { + if let Some(true) = webauthn.passkey()?.update_credential(&auth_result) { + webauthn.save(&appstate.pool).await?; + } + } + } + session + .set_state(&appstate.pool, SessionState::MultiFactorVerified) + .await?; + return Ok(ApiResponse::default()); + } + } + Err(OriWebError::Http(Status::BadRequest)) +} + +// Generate new TOTP secret +#[post("/auth/totp/init")] +pub async fn totp_secret(session: SessionInfo, appstate: &State) -> ApiResult { + let mut user = session.user; + + let secret = user.new_secret(&appstate.pool).await?; + Ok(ApiResponse { + json: json!(AuthTotp::new(secret)), + status: Status::Ok, + }) +} + +/// Enable TOTP +#[put("/auth/totp", format = "json", data = "")] +pub async fn totp_enable( + session: SessionInfo, + appstate: &State, + data: Json, +) -> ApiResult { + let mut user = session.user; + if user.verify_code(data.code) { + user.enable_totp(&appstate.pool).await?; + Ok(ApiResponse::default()) + } else { + Err(OriWebError::ObjectNotFound("Invalid TOTP code".into())) + } +} + +/// Disable TOTP +#[delete("/auth/totp")] +pub async fn totp_disable(session: SessionInfo, appstate: &State) -> ApiResult { + let mut user = session.user; + user.disable_totp(&appstate.pool).await?; + Ok(ApiResponse::default()) +} + +/// Validate one-time passcode +#[post("/auth/totp", format = "json", data = "")] +pub async fn totp_code( + mut session: Session, + appstate: &State, + data: Json, +) -> ApiResult { + if let Some(user) = User::find_by_id(&appstate.pool, session.user_id).await? { + if user.totp_enabled && user.verify_code(data.code) { + session + .set_state(&appstate.pool, SessionState::MultiFactorVerified) + .await?; + Ok(ApiResponse::default()) + } else { + Err(OriWebError::Authorization("Invalid TOTP code".into())) + } + } else { + Err(OriWebError::ObjectNotFound("Invalid user".into())) + } +} + +/// Start Web3 authentication +#[post("/auth/web3/start")] +pub async fn web3auth_start(mut session: Session, appstate: &State) -> ApiResult { + match Settings::find_by_id(&appstate.pool, 1).await? { + Some(settings) => { + session + .set_web3_challenge(&appstate.pool, settings.challenge_template.clone()) + .await?; + Ok(ApiResponse { + json: json!({"challenge": settings.challenge_template}), + status: Status::Ok, + }) + } + None => Err(OriWebError::DbError("cannot retrieve settings".into())), + } +} + +/// Finish Web3 authentication +#[post("/auth/web3", format = "json", data = "")] +pub async fn web3auth_end( + mut session: Session, + appstate: &State, + signature: Json, +) -> ApiResult { + if let Some(ref challenge) = session.web3_challenge { + if let Some(wallet) = + Wallet::find_by_user_and_address(&appstate.pool, session.user_id, &signature.address) + .await? + { + if wallet.use_for_mfa { + return match wallet.verify_address(challenge, &signature.signature) { + Ok(true) => { + session + .set_state(&appstate.pool, SessionState::MultiFactorVerified) + .await?; + Ok(ApiResponse::default()) + } + _ => Err(OriWebError::Authorization("Signature not verified".into())), + }; + } + } + } + Err(OriWebError::Http(Status::BadRequest)) +} diff --git a/src/handlers/group.rs b/src/handlers/group.rs new file mode 100644 index 000000000..0e3ef6f88 --- /dev/null +++ b/src/handlers/group.rs @@ -0,0 +1,153 @@ +use super::Username; +use crate::{ + appstate::AppState, + auth::{AdminRole, SessionInfo}, + db::{Group, User}, + enterprise::ldap::utils::{ldap_add_user_to_group, ldap_remove_user_from_group}, + error::OriWebError, + handlers::{ApiResponse, ApiResult}, +}; +use rocket::{ + http::Status, + serde::json::{serde_json::json, Json}, + State, +}; + +#[derive(Serialize)] +pub struct Groups { + groups: Vec, +} + +impl Groups { + #[must_use] + pub fn new(groups: Vec) -> Self { + Self { groups } + } +} + +#[derive(Serialize)] +pub struct GroupInfo { + name: String, + members: Vec, +} + +impl GroupInfo { + #[must_use] + pub fn new(name: String, members: Vec) -> Self { + Self { name, members } + } +} + +#[get("/group", format = "json")] +pub async fn list_groups(_session: SessionInfo, appstate: &State) -> ApiResult { + debug!("Listing groups"); + let groups = Group::all(&appstate.pool) + .await? + .into_iter() + .map(|group| group.name) + .collect(); + info!("Listed groups"); + Ok(ApiResponse { + json: json!(Groups::new(groups)), + status: Status::Ok, + }) +} + +#[get("/group/", format = "json")] +pub async fn get_group(_session: SessionInfo, appstate: &State, name: &str) -> ApiResult { + debug!("Retrieving group {}", name); + match Group::find_by_name(&appstate.pool, name).await? { + Some(group) => { + let members = group.member_usernames(&appstate.pool).await?; + info!("Retrieved group {}", name); + Ok(ApiResponse { + json: json!(GroupInfo::new(name.into(), members)), + status: Status::Ok, + }) + } + None => { + error!("Group {} not found", name); + Err(OriWebError::ObjectNotFound(format!( + "Group {} not found", + name + ))) + } + } +} + +#[post("/group/", format = "json", data = "")] +pub async fn add_group_member( + _admin: AdminRole, + appstate: &State, + name: &str, + data: Json, +) -> ApiResult { + match Group::find_by_name(&appstate.pool, name).await? { + Some(group) => match User::find_by_username(&appstate.pool, &data.username).await? { + Some(user) => { + debug!("Adding user: {} to group: {}", user.username, group.name); + user.add_to_group(&appstate.pool, &group).await?; + let _result = + ldap_add_user_to_group(&appstate.config, &user.username, &group.name).await; + info!("Added user: {} to group: {}", user.username, group.name); + Ok(ApiResponse::default()) + } + None => { + error!("User not found {}", data.username); + Err(OriWebError::ObjectNotFound(format!( + "User {} not found", + data.username + ))) + } + }, + None => { + error!("Group {} not found", name); + Err(OriWebError::ObjectNotFound(format!( + "Group {} not found", + name + ))) + } + } +} + +#[delete("/group//user/")] +pub async fn remove_group_member( + _admin: AdminRole, + appstate: &State, + name: &str, + username: &str, +) -> ApiResult { + match Group::find_by_name(&appstate.pool, name).await? { + Some(group) => match User::find_by_username(&appstate.pool, username).await? { + Some(user) => { + debug!( + "Removing user: {} from group: {}", + user.username, group.name + ); + user.remove_from_group(&appstate.pool, &group).await?; + let _result = + ldap_remove_user_from_group(&appstate.config, &user.username, &group.name) + .await; + info!("Removed user: {} from group: {}", user.username, group.name); + Ok(ApiResponse { + json: json!({}), + status: Status::Ok, + }) + } + None => { + error!("User not found {}", username); + Err(OriWebError::ObjectNotFound(format!( + "User {} not found", + username + ))) + } + }, + None => { + error!("Group {} not found", name); + Err(OriWebError::ObjectNotFound(format!( + "Group {} not found", + name + ))) + } + } +} diff --git a/src/handlers/license.rs b/src/handlers/license.rs new file mode 100644 index 000000000..d543c9274 --- /dev/null +++ b/src/handlers/license.rs @@ -0,0 +1,16 @@ +use crate::{ + appstate::AppState, + auth::SessionInfo, + handlers::{ApiResponse, ApiResult}, + license::License, +}; +use rocket::{http::Status, serde::json::serde_json::json, State}; + +#[get("/license", format = "json")] +pub fn get_license(_session: SessionInfo, appstate: &State) -> ApiResult { + let license = License::decode(&appstate.config.license); + Ok(ApiResponse { + json: json!(license), + status: Status::Ok, + }) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 000000000..10bab0a12 --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,208 @@ +#[cfg(feature = "wireguard")] +use crate::db::Device; +use crate::{ + auth::SessionInfo, + db::{DbPool, User}, + error::OriWebError, +}; +use rocket::{ + http::{ContentType, Status}, + request::Request, + response::{Responder, Response}, + serde::json::{serde_json::json, Value}, +}; +use std::env; +use webauthn_rs::prelude::RegisterPublicKeyCredential; + +pub(crate) mod auth; +pub(crate) mod group; +pub(crate) mod license; +pub(crate) mod settings; +pub(crate) mod user; +pub(crate) mod webhooks; +#[cfg(feature = "wireguard")] +pub mod wireguard; + +#[derive(Default)] +pub struct ApiResponse { + pub json: Value, + pub status: Status, +} + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub type ApiResult = Result; + +fn internal_server_error(msg: &str) -> (Value, Status) { + error!("{}", msg); + ( + json!({"msg": "Internal server error"}), + Status::InternalServerError, + ) +} + +impl<'r, 'o: 'r> Responder<'r, 'o> for OriWebError { + fn respond_to(self, request: &'r Request<'_>) -> Result, Status> { + let (json, status) = match self { + OriWebError::ObjectNotFound(msg) => (json!({ "msg": msg }), Status::NotFound), + OriWebError::Authorization(msg) => { + error!("{}", msg); + (json!({ "msg": msg }), Status::Unauthorized) + } + OriWebError::Forbidden(msg) => { + error!("{}", msg); + (json!({ "msg": msg }), Status::Forbidden) + } + OriWebError::DbError(msg) => internal_server_error(&msg), + OriWebError::Grpc(msg) => internal_server_error(&msg), + OriWebError::Ldap(msg) => internal_server_error(&msg), + OriWebError::IncorrectUsername(msg) => { + error!("{}", msg); + (json!({ "msg": msg }), Status::BadRequest) + } + OriWebError::Serialization(msg) => internal_server_error(&msg), + OriWebError::ModelError(msg) => internal_server_error(&msg), + OriWebError::Http(status) => { + error!("{}", status); + (json!({ "msg": status.reason_lossy() }), status) + } + }; + Response::build_from(json.respond_to(request)?) + .status(status) + .header(ContentType::JSON) + .raw_header("X-Defguard-Version", VERSION) + .ok() + } +} + +impl<'r, 'o: 'r> Responder<'r, 'o> for ApiResponse { + fn respond_to(self, request: &'r Request<'_>) -> Result, Status> { + Response::build_from(self.json.respond_to(request)?) + .status(self.status) + .header(ContentType::JSON) + .raw_header("X-Defguard-Version", VERSION) + .ok() + } +} + +#[derive(Deserialize, Serialize)] +pub struct Auth { + username: String, + password: String, +} + +impl Auth { + #[must_use] + pub fn new(username: String, password: String) -> Self { + Self { username, password } + } +} + +#[derive(Deserialize, Serialize)] +pub struct AuthTotp { + pub secret: String, +} + +impl AuthTotp { + #[must_use] + pub fn new(secret: String) -> Self { + Self { secret } + } +} + +#[derive(Deserialize, Serialize)] +pub struct AuthCode { + code: u32, +} + +impl AuthCode { + pub fn new(code: u32) -> Self { + Self { code } + } +} + +#[derive(Deserialize, Serialize)] +pub struct Username { + pub username: String, +} + +#[derive(Deserialize, Serialize)] +pub struct AddUserData { + pub username: String, + pub last_name: String, + pub first_name: String, + pub email: String, + pub phone: String, + pub password: String, +} + +#[derive(Deserialize, Serialize)] +pub struct PasswordChange { + pub new_password: String, +} + +#[derive(Deserialize)] +pub struct WalletSignature { + pub address: String, + pub signature: String, +} + +#[derive(Deserialize, Serialize)] +pub struct WalletChallenge { + pub id: i64, + pub message: String, +} + +#[derive(Deserialize)] +pub struct WalletChange { + pub use_for_mfa: bool, +} + +#[derive(Deserialize)] +pub struct WebAuthnRegistration { + pub name: String, + pub rpkc: RegisterPublicKeyCredential, +} + +/// Try to fetch [`User`] if the username is of the currently logged in user, or +/// the logged in user is an admin. +pub async fn user_for_admin_or_self( + pool: &DbPool, + session: &SessionInfo, + username: &str, +) -> Result { + if session.user.username == username || session.is_admin { + match User::find_by_username(pool, username).await? { + Some(user) => Ok(user), + None => Err(OriWebError::ObjectNotFound(format!( + "user {} not found", + username + ))), + } + } else { + Err(OriWebError::Forbidden("requires privileged access".into())) + } +} + +/// Try to fetch [`Device'] if the device.id is of the currently logged in user, or +/// the logged in user is an admin. +#[cfg(feature = "wireguard")] +pub async fn device_for_admin_or_self( + pool: &DbPool, + session: &SessionInfo, + id: i64, +) -> Result { + let fetch = if session.is_admin { + Device::find_by_id(pool, id).await + } else { + Device::find_by_id_and_username(pool, id, &session.user.username).await + }?; + + match fetch { + Some(device) => Ok(device), + None => Err(OriWebError::ObjectNotFound(format!( + "device id {} not found", + id + ))), + } +} diff --git a/src/handlers/settings.rs b/src/handlers/settings.rs new file mode 100644 index 000000000..4124a5f77 --- /dev/null +++ b/src/handlers/settings.rs @@ -0,0 +1,31 @@ +use super::{ApiResponse, ApiResult}; +use crate::{auth::AdminRole, db::Settings, AppState}; +use rocket::{ + http::Status, + serde::json::{serde_json::json, Json}, + State, +}; + +#[get("/settings", format = "json")] +pub async fn get_settings(_admin: AdminRole, appstate: &State) -> ApiResult { + debug!("Retrieving settings"); + let settings = Settings::find_by_id(&appstate.pool, 1).await?; + info!("Retrieved settings"); + Ok(ApiResponse { + json: json!(settings), + status: Status::Ok, + }) +} + +#[put("/settings", format = "json", data = "")] +pub async fn update_settings( + _admin: AdminRole, + appstate: &State, + mut data: Json, +) -> ApiResult { + debug!("Updating settings"); + data.id = Some(1); + data.save(&appstate.pool).await?; + info!("Settings updated"); + Ok(ApiResponse::default()) +} diff --git a/src/handlers/user.rs b/src/handlers/user.rs new file mode 100644 index 000000000..e6fd5b62b --- /dev/null +++ b/src/handlers/user.rs @@ -0,0 +1,321 @@ +use super::{ + user_for_admin_or_self, AddUserData, ApiResponse, ApiResult, PasswordChange, Username, + WalletChallenge, WalletChange, WalletSignature, +}; +use crate::{ + appstate::AppState, + auth::{AdminRole, SessionInfo}, + db::{AppEvent, Settings, User, UserInfo, Wallet, WebAuthn}, + enterprise::ldap::utils::{ + ldap_add_user, ldap_change_password, ldap_delete_user, ldap_modify_user, + }, + error::OriWebError, + license::Features, +}; +use rocket::{ + http::Status, + serde::json::{serde_json::json, Json}, + State, +}; + +/// Verify the given username consists of all ASCII digits or lowercase characters. +fn check_username(username: &str) -> Result<(), OriWebError> { + if username + .chars() + .all(|c| c.is_ascii_digit() || c.is_ascii_lowercase()) + { + Ok(()) + } else { + Err(OriWebError::IncorrectUsername(username.into())) + } +} + +#[get("/user", format = "json")] +pub async fn list_users(_session: SessionInfo, appstate: &State) -> ApiResult { + debug!("Listing users"); + let all_users = User::all(&appstate.pool).await?; + let mut users: Vec = Vec::with_capacity(all_users.len()); + for user in all_users { + users.push(UserInfo::from_user(&appstate.pool, user).await?); + } + info!("Listed users"); + Ok(ApiResponse { + json: json!(users), + status: Status::Ok, + }) +} + +#[get("/user/", format = "json")] +pub async fn get_user( + session: SessionInfo, + appstate: &State, + username: &str, +) -> ApiResult { + debug!("Retrieving user {}", username); + let user = user_for_admin_or_self(&appstate.pool, &session, username).await?; + info!("Retrieved user {}", username); + let user_info = UserInfo::from_user(&appstate.pool, user).await?; + Ok(ApiResponse { + json: json!(user_info), + status: Status::Ok, + }) +} + +#[post("/user", format = "json", data = "")] +pub async fn add_user( + _admin: AdminRole, + appstate: &State, + data: Json, +) -> ApiResult { + let user_data = data.into_inner(); + let password = user_data.password.clone(); + check_username(&user_data.username)?; + let mut user = User::new( + user_data.username, + &user_data.password, + user_data.last_name, + user_data.first_name, + user_data.email, + Some(user_data.phone), + ); + user.save(&appstate.pool).await?; + if appstate.license.validate(&Features::Ldap) { + let _result = ldap_add_user(&appstate.config, &user, &password).await; + }; + let user_info = UserInfo::from_user(&appstate.pool, user).await?; + appstate.trigger_action(AppEvent::UserCreated(user_info)); + Ok(ApiResponse { + json: json!({}), + status: Status::Created, + }) +} + +#[post("/user/available", format = "json", data = "")] +pub async fn username_available( + _session: SessionInfo, + appstate: &State, + data: Json, +) -> ApiResult { + check_username(&data.username)?; + let status = match User::find_by_username(&appstate.pool, &data.username).await? { + Some(_) => Status::BadRequest, + None => Status::Ok, + }; + Ok(ApiResponse { + json: json!({}), + status, + }) +} + +// XXX: must ignore UserInfo.groups +#[put("/user/", format = "json", data = "")] +pub async fn modify_user( + session: SessionInfo, + appstate: &State, + username: &str, + data: Json, +) -> ApiResult { + debug!("Modifing user {}", username); + let mut user = user_for_admin_or_self(&appstate.pool, &session, username).await?; + data.into_inner().into_user(&mut user); + user.save(&appstate.pool).await?; + if appstate.license.validate(&Features::Ldap) { + let _result = ldap_modify_user(&appstate.config, username, &user).await; + }; + info!("Modified user {}", username); + let user_info = UserInfo::from_user(&appstate.pool, user).await?; + appstate.trigger_action(AppEvent::UserModified(user_info)); + Ok(ApiResponse::default()) +} + +#[delete("/user/")] +pub async fn delete_user( + _admin: AdminRole, + appstate: &State, + username: &str, +) -> ApiResult { + debug!("Deleting user {}", username); + match User::find_by_username(&appstate.pool, username).await? { + Some(user) => { + user.delete(&appstate.pool).await?; + if appstate.license.validate(&Features::Ldap) { + let _result = ldap_delete_user(&appstate.config, username).await; + }; + info!("Deleted user {}", username); + appstate.trigger_action(AppEvent::UserDeleted(username.into())); + Ok(ApiResponse::default()) + } + None => { + error!("User {} not found", username); + Err(OriWebError::ObjectNotFound(format!( + "User {} not found", + username + ))) + } + } +} + +#[put("/user//password", format = "json", data = "")] +pub async fn change_password( + session: SessionInfo, + appstate: &State, + username: &str, + data: Json, +) -> ApiResult { + debug!("Changing password for user {}", username); + let mut user = user_for_admin_or_self(&appstate.pool, &session, username).await?; + user.set_password(&data.new_password); + user.save(&appstate.pool).await?; + if appstate.license.validate(&Features::Ldap) { + let _result = ldap_change_password(&appstate.config, username, &data.new_password).await; + } + info!("Password changed for user {}", username); + Ok(ApiResponse::default()) +} + +#[get("/user//challenge?
&&")] +pub async fn wallet_challenge( + session: SessionInfo, + appstate: &State, + username: &str, + address: &str, + name: &str, + chain_id: i64, +) -> ApiResult { + let user = user_for_admin_or_self(&appstate.pool, &session, username).await?; + + // check if address already exists + let wallet = + match Wallet::find_by_user_and_address(&appstate.pool, user.id.unwrap(), address).await? { + Some(wallet) => { + if wallet.validation_timestamp.is_some() { + return Err(OriWebError::ObjectNotFound("wrong address".into())); + } + wallet + } + None => { + let challenge_message = match Settings::find_by_id(&appstate.pool, 1).await? { + Some(settings) => settings.challenge_template, + None => return Err(OriWebError::DbError("cannot retrieve settings".into())), + }; + let mut wallet = Wallet::new_for_user( + user.id.unwrap(), + address.into(), + name.into(), + chain_id, + challenge_message, + ); + wallet.save(&appstate.pool).await?; + wallet + } + }; + + Ok(ApiResponse { + json: json!(WalletChallenge { + id: wallet.id.unwrap(), + message: wallet.challenge_message + }), + status: Status::Ok, + }) +} + +#[put("/user//wallet", format = "json", data = "")] +pub async fn set_wallet( + session: SessionInfo, + appstate: &State, + username: &str, + data: Json, +) -> ApiResult { + let user = user_for_admin_or_self(&appstate.pool, &session, username).await?; + let wallet_info = data.into_inner(); + if let Some(mut wallet) = + Wallet::find_by_user_and_address(&appstate.pool, user.id.unwrap(), &wallet_info.address) + .await? + { + if wallet.validate_signature(&wallet_info.signature).is_ok() { + wallet + .set_signature(&appstate.pool, &wallet_info.signature) + .await?; + Ok(ApiResponse::default()) + } else { + Err(OriWebError::ObjectNotFound("wrong address".into())) + } + } else { + Err(OriWebError::ObjectNotFound("wallet not found".into())) + } +} + +#[put("/user//wallet/
", format = "json", data = "")] +pub async fn update_wallet( + session: SessionInfo, + appstate: &State, + username: &str, + address: &str, + data: Json, +) -> ApiResult { + let user = user_for_admin_or_self(&appstate.pool, &session, username).await?; + if let Some(mut wallet) = + Wallet::find_by_user_and_address(&appstate.pool, user.id.unwrap(), address).await? + { + if Some(wallet.user_id) == user.id { + wallet.use_for_mfa = data.use_for_mfa; + wallet.save(&appstate.pool).await?; + Ok(ApiResponse::default()) + } else { + Err(OriWebError::ObjectNotFound("wrong wallet".into())) + } + } else { + Err(OriWebError::ObjectNotFound("wallet not found".into())) + } +} + +#[delete("/user//wallet/
")] +pub async fn delete_wallet( + session: SessionInfo, + appstate: &State, + username: &str, + address: &str, +) -> ApiResult { + let user = user_for_admin_or_self(&appstate.pool, &session, username).await?; + if let Some(wallet) = + Wallet::find_by_user_and_address(&appstate.pool, user.id.unwrap(), address).await? + { + if Some(wallet.user_id) == user.id { + wallet.delete(&appstate.pool).await?; + Ok(ApiResponse::default()) + } else { + Err(OriWebError::ObjectNotFound("wrong wallet".into())) + } + } else { + Err(OriWebError::ObjectNotFound("wallet not found".into())) + } +} + +#[delete("/user//security_key/")] +pub async fn delete_security_key( + session: SessionInfo, + appstate: &State, + username: &str, + id: i64, +) -> ApiResult { + let user = user_for_admin_or_self(&appstate.pool, &session, username).await?; + if let Some(webauthn) = WebAuthn::find_by_id(&appstate.pool, id).await? { + if Some(webauthn.user_id) == user.id { + webauthn.delete(&appstate.pool).await?; + Ok(ApiResponse::default()) + } else { + Err(OriWebError::ObjectNotFound("wrong security key".into())) + } + } else { + Err(OriWebError::ObjectNotFound("security key not found".into())) + } +} + +#[get("/me", format = "json")] +pub async fn me(session: SessionInfo, appstate: &State) -> ApiResult { + let user_info = UserInfo::from_user(&appstate.pool, session.user).await?; + Ok(ApiResponse { + json: json!(user_info), + status: Status::Ok, + }) +} diff --git a/src/handlers/webhooks.rs b/src/handlers/webhooks.rs new file mode 100644 index 000000000..a73d447a6 --- /dev/null +++ b/src/handlers/webhooks.rs @@ -0,0 +1,134 @@ +use crate::{ + appstate::AppState, + auth::AdminRole, + db::WebHook, + handlers::{ApiResponse, ApiResult}, +}; +use rocket::{ + http::Status, + serde::json::{serde_json::json, Json}, + State, +}; + +#[post("/", format = "json", data = "")] +pub async fn add_webhook( + _admin: AdminRole, + appstate: &State, + data: Json, +) -> ApiResult { + let mut webhook = data.into_inner(); + let status = match webhook.save(&appstate.pool).await { + Ok(_) => Status::Created, + Err(_) => Status::BadRequest, + }; + Ok(ApiResponse { + json: json!({}), + status, + }) +} + +#[get("/", format = "json")] +// TODO: paginate +pub async fn list_webhooks(_admin: AdminRole, appstate: &State) -> ApiResult { + let webhooks = WebHook::all(&appstate.pool).await?; + Ok(ApiResponse { + json: json!(webhooks), + status: Status::Ok, + }) +} + +#[get("/", format = "json")] +pub async fn get_webhook(_admin: AdminRole, appstate: &State, id: i64) -> ApiResult { + match WebHook::find_by_id(&appstate.pool, id).await? { + Some(webhook) => Ok(ApiResponse { + json: json!(webhook), + status: Status::Ok, + }), + None => Ok(ApiResponse { + json: json!({}), + status: Status::NotFound, + }), + } +} + +#[derive(Deserialize, Serialize)] +pub struct WebHookData { + pub url: String, + pub description: String, + pub token: String, + pub enabled: bool, + pub on_user_created: bool, + pub on_user_deleted: bool, + pub on_user_modified: bool, + pub on_hwkey_provision: bool, +} + +#[put("/", format = "json", data = "")] +pub async fn change_webhook( + _admin: AdminRole, + appstate: &State, + id: i64, + data: Json, +) -> ApiResult { + let status = match WebHook::find_by_id(&appstate.pool, id).await? { + Some(mut webhook) => { + let data = data.into_inner(); + webhook.url = data.url; + webhook.description = data.description; + webhook.token = data.token; + webhook.enabled = data.enabled; + webhook.on_user_created = data.on_user_created; + webhook.on_user_deleted = data.on_user_deleted; + webhook.on_user_modified = data.on_user_modified; + webhook.on_hwkey_provision = data.on_hwkey_provision; + webhook.save(&appstate.pool).await?; + Status::Ok + } + None => Status::NotFound, + }; + Ok(ApiResponse { + json: json!({}), + status, + }) +} + +#[delete("/")] +pub async fn delete_webhook(_admin: AdminRole, appstate: &State, id: i64) -> ApiResult { + let status = match WebHook::find_by_id(&appstate.pool, id).await? { + Some(webhook) => { + webhook.delete(&appstate.pool).await?; + Status::Ok + } + None => Status::NotFound, + }; + Ok(ApiResponse { + json: json!({}), + status, + }) +} + +#[derive(Deserialize)] +pub struct ChangeStateData { + pub enabled: bool, +} + +#[post("/", format = "json", data = "")] +pub async fn change_enabled( + _admin: AdminRole, + appstate: &State, + id: i64, + data: Json, +) -> ApiResult { + let status = match WebHook::find_by_id(&appstate.pool, id).await? { + Some(mut webhook) => { + webhook.enabled = data.enabled; + webhook.save(&appstate.pool).await?; + Status::Ok + } + None => Status::NotFound, + }; + Ok(ApiResponse { + json: json!({}), + status, + }) +} diff --git a/src/handlers/wireguard.rs b/src/handlers/wireguard.rs new file mode 100644 index 000000000..fd47807c9 --- /dev/null +++ b/src/handlers/wireguard.rs @@ -0,0 +1,360 @@ +use super::{ + device_for_admin_or_self, user_for_admin_or_self, ApiResponse, ApiResult, OriWebError, +}; +use crate::{ + appstate::AppState, + auth::{AdminRole, Claims, SessionInfo}, + db::{ + models::wireguard::DateTimeAggregation, AddDevice, DbPool, Device, GatewayEvent, + WireguardNetwork, + }, +}; +use chrono::{DateTime, Duration, NaiveDateTime, Utc}; +use ipnetwork::IpNetwork; +use rocket::{ + http::Status, + serde::{ + json::{json, Json}, + Deserialize, + }, + State, +}; +use std::str::FromStr; + +#[derive(Deserialize, Serialize)] +pub struct WireguardNetworkData { + pub name: String, + pub address: IpNetwork, + pub endpoint: String, + pub port: i32, + pub allowed_ips: Option, + pub dns: Option, +} + +impl WireguardNetworkData { + pub(crate) fn parse_allowed_ips(&self) -> Vec { + self.allowed_ips.as_ref().map_or(Vec::new(), |ips| { + ips.split(',') + .filter_map(|ip| ip.trim().parse().ok()) + .collect() + }) + } +} + +#[post("/", format = "json", data = "")] +pub async fn create_network( + _admin: AdminRole, + data: Json, + appstate: &State, +) -> ApiResult { + debug!("Creating WireGuard network",); + let data = data.into_inner(); + let allowed_ips = data.parse_allowed_ips(); + let mut network = WireguardNetwork::new( + data.name, + data.address, + data.port, + data.endpoint, + data.dns, + allowed_ips, + ) + .map_err(|_| OriWebError::Serialization("Invalid network address".into()))?; + network.save(&appstate.pool).await?; + appstate.send_wireguard_event(GatewayEvent::NetworkCreated(network.clone())); + info!("Created WireGuard network"); + Ok(ApiResponse { + json: json!(network), + status: Status::Created, + }) +} + +async fn find_network(id: i64, pool: &DbPool) -> Result { + WireguardNetwork::find_by_id(pool, id) + .await? + .ok_or_else(|| OriWebError::ObjectNotFound(format!("Network {} not found", id))) +} + +#[put("/", format = "json", data = "")] +pub async fn modify_network( + _admin: AdminRole, + id: i64, + data: Json, + appstate: &State, +) -> ApiResult { + debug!("Modifying network id: {}", id); + let mut network = find_network(id, &appstate.pool).await?; + let data = data.into_inner(); + network.allowed_ips = data.parse_allowed_ips(); + network.name = data.name; + network.change_address(&appstate.pool, data.address).await?; + network.endpoint = data.endpoint; + network.port = data.port; + network.dns = data.dns; + network.save(&appstate.pool).await?; + appstate.send_wireguard_event(GatewayEvent::NetworkModified(network.clone())); + info!("Modified network id: {}", id); + Ok(ApiResponse { + json: json!(network), + status: Status::Ok, + }) +} + +#[delete("/")] +pub async fn delete_network(_admin: AdminRole, id: i64, appstate: &State) -> ApiResult { + debug!("Deleting network id: {}", id); + let network = find_network(id, &appstate.pool).await?; + let network_name = network.name.clone(); + network.delete(&appstate.pool).await?; + appstate.send_wireguard_event(GatewayEvent::NetworkDeleted(network_name)); + info!("Deleted network id: {}", id); + Ok(ApiResponse::default()) +} + +#[get("/", format = "json")] +pub async fn list_networks(_admin: AdminRole, appstate: &State) -> ApiResult { + debug!("Listing WireGuard networks"); + let networks = WireguardNetwork::all(&appstate.pool).await?; + info!("Listed WireGuard networks"); + + Ok(ApiResponse { + json: json!(networks), + status: Status::Ok, + }) +} + +#[get("/", format = "json")] +pub async fn network_details( + network_id: i64, + _admin: AdminRole, + appstate: &State, +) -> ApiResult { + debug!("Displaying network details for network {}", network_id); + let network = WireguardNetwork::find_by_id(&appstate.pool, network_id).await?; + info!("Displayed network details for network {}", network_id); + + Ok(ApiResponse { + json: json!(network), + status: Status::Ok, + }) +} + +#[post("/device/", format = "json", data = "")] +pub async fn add_device( + session: SessionInfo, + appstate: &State, + username: &str, + data: Json, +) -> ApiResult { + let user = user_for_admin_or_self(&appstate.pool, &session, username).await?; + debug!("Adding device for user: {}", username); + // FIXME: hard-coded network id + if let Ok(Some(network)) = WireguardNetwork::find_by_id(&appstate.pool, 1).await { + if network.pubkey == data.wireguard_pubkey { + return Ok(ApiResponse { + json: json!({"msg": "device's pubkey must be differnet from server's pubkey"}), + status: Status::BadRequest, + }); + } + + let add_device = data.into_inner(); + let mut device = Device::assign_device_ip( + &appstate.pool, + user.id.unwrap(), + add_device.name, + add_device.wireguard_pubkey, + &network, + ) + .await?; + device.save(&appstate.pool).await?; + appstate.send_wireguard_event(GatewayEvent::DeviceCreated(device.clone())); + info!( + "Added WireGuard device {} for user: {}", + device.id.unwrap(), + username + ); + Ok(ApiResponse { + json: json!(device), + status: Status::Created, + }) + } else { + error!("No network found, can't add device"); + Ok(ApiResponse { + json: json!({}), + status: Status::BadRequest, + }) + } +} + +#[put("/device/", format = "json", data = "")] +pub async fn modify_device( + session: SessionInfo, + id: i64, + data: Json, + appstate: &State, +) -> ApiResult { + debug!("Modifying device with id: {}", id); + let mut device = device_for_admin_or_self(&appstate.pool, &session, id).await?; + + // FIXME: hard-coded network id + if let Ok(Some(network)) = WireguardNetwork::find_by_id(&appstate.pool, 1).await { + if network.pubkey == data.wireguard_pubkey { + return Ok(ApiResponse { + json: json!({"msg": "device's pubkey must be differnet from server's pubkey"}), + status: Status::BadRequest, + }); + } + + device.update_from(data.into_inner()); + device.save(&appstate.pool).await?; + appstate.send_wireguard_event(GatewayEvent::DeviceModified(device.clone())); + Ok(ApiResponse { + json: json!(device), + status: Status::Ok, + }) + } else { + error!("No network found can't add device"); + Ok(ApiResponse { + json: json!({}), + status: Status::BadRequest, + }) + } +} + +#[get("/device/", format = "json")] +pub async fn get_device(session: SessionInfo, id: i64, appstate: &State) -> ApiResult { + debug!("Retrieving device with id: {}", id); + let device = device_for_admin_or_self(&appstate.pool, &session, id).await?; + Ok(ApiResponse { + json: json!(device), + status: Status::Ok, + }) +} + +#[delete("/device/")] +pub async fn delete_device(session: SessionInfo, id: i64, appstate: &State) -> ApiResult { + debug!("Removing device with id: {}", id); + let device = device_for_admin_or_self(&appstate.pool, &session, id).await?; + let device_pubkey = device.wireguard_pubkey.clone(); + device.delete(&appstate.pool).await?; + appstate.send_wireguard_event(GatewayEvent::DeviceDeleted(device_pubkey)); + info!("Removed device with id: {}", id); + Ok(ApiResponse::default()) +} + +#[get("/device", format = "json")] +pub async fn list_devices(_admin: AdminRole, appstate: &State) -> ApiResult { + debug!("Listing devices"); + let devices = Device::all(&appstate.pool).await?; + info!("Listed devices"); + + Ok(ApiResponse { + json: json!(devices), + status: Status::Ok, + }) +} + +#[get("/device/user/", format = "json")] +pub async fn list_user_devices( + _session: SessionInfo, + appstate: &State, + username: &str, +) -> ApiResult { + debug!("Listing devices for user: {}", username); + let devices = Device::all_for_username(&appstate.pool, username).await?; + info!("Listed devices for user: {}", username); + + Ok(ApiResponse { + json: json!(devices), + status: Status::Ok, + }) +} + +// FIXME: conflicts with /device/user/ +#[get("/device//config", rank = 2, format = "json")] +pub async fn download_config( + session: SessionInfo, + appstate: &State, + id: i64, +) -> Result { + let network = find_network(1, &appstate.pool).await?; + let device = device_for_admin_or_self(&appstate.pool, &session, id).await?; + Ok(device.create_config(network)) +} + +#[get("/token/", format = "json")] +pub async fn create_network_token( + _admin: AdminRole, + appstate: &State, + id: i64, +) -> ApiResult { + let network = find_network(id, &appstate.pool).await?; + let token = Claims::new(network.name.clone(), String::new(), u32::MAX.into()) + .to_jwt() + .map_err(|_| { + OriWebError::Authorization(format!( + "Failed to create token for gateway {}", + network.name + )) + })?; + Ok(ApiResponse { + json: json!({ "token": token }), + status: Status::Ok, + }) +} + +/// Returns appropriate aggregation level depending on the `from` date param +/// If `from` is >= than 6 hours ago, returns `Hour` aggregation +/// Otherwise returns `Minute` aggregation +fn get_aggregation(from: NaiveDateTime) -> Result { + // Use hourly aggregation for longer periods + let aggregation = match Utc::now().naive_utc() - from { + duration if duration >= Duration::hours(6) => Ok(DateTimeAggregation::Hour), + duration if duration < Duration::zero() => Err(Status::BadRequest), + _ => Ok(DateTimeAggregation::Minute), + }?; + Ok(aggregation) +} + +/// If `datetime` is Some, parses the date string, otherwise returns `DateTime` one hour ago. +fn parse_timestamp(datetime: Option) -> Result, Status> { + Ok(match datetime { + Some(from) => DateTime::::from_str(&from).map_err(|_| Status::BadRequest)?, + None => Utc::now() - Duration::hours(1), + }) +} + +#[get("/stats/users?", format = "json")] +pub async fn user_stats( + _admin: AdminRole, + appstate: &State, + from: Option, +) -> ApiResult { + debug!("Displaying wireguard user stats"); + let from = parse_timestamp(from)?.naive_utc(); + let aggregation = get_aggregation(from)?; + let stats = WireguardNetwork::user_stats(&appstate.pool, &from, &aggregation).await?; + info!("Displayed wireguard user stats"); + + Ok(ApiResponse { + json: json!(stats), + status: Status::Ok, + }) +} + +#[get("/stats?", format = "json")] +pub async fn network_stats( + _admin: AdminRole, + appstate: &State, + from: Option, +) -> ApiResult { + debug!("Displaying wireguard network stats"); + let from = parse_timestamp(from)?.naive_utc(); + let aggregation = get_aggregation(from)?; + let stats = WireguardNetwork::network_stats(&appstate.pool, &from, &aggregation).await?; + info!("Displayed wireguard network stats"); + + Ok(ApiResponse { + json: json!(stats), + status: Status::Ok, + }) +} diff --git a/src/hex.rs b/src/hex.rs new file mode 100644 index 000000000..398a0554e --- /dev/null +++ b/src/hex.rs @@ -0,0 +1,73 @@ +use std::{ + error::Error, + fmt::{Display, Formatter, Result as FmtResult}, +}; + +#[derive(Debug, PartialEq)] +pub enum HexError { + InvalidCharacter(u8), + InvalidStringLength(usize), +} + +impl Error for HexError {} + +impl Display for HexError { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + match self { + Self::InvalidCharacter(char) => { + write!(f, "Invalid character {char}") + } + Self::InvalidStringLength(length) => write!(f, "Invalid string length {length}"), + } + } +} + +pub fn hex_decode>(hex: T) -> Result, HexError> { + let mut hex = hex.as_ref(); + let mut length = hex.len(); + if length == 0 || length % 2 != 0 { + return Err(HexError::InvalidStringLength(length)); + } + + if length > 2 && hex[0] == b'0' && (hex[1] == b'x' || hex[1] == b'X') { + length -= 2; + hex = &hex[2..]; + } + + let hex_value = |char: u8| -> Result { + match char { + b'A'..=b'F' => Ok(char - b'A' + 10), + b'a'..=b'f' => Ok(char - b'a' + 10), + b'0'..=b'9' => Ok(char - b'0'), + _ => Err(HexError::InvalidCharacter(char)), + } + }; + + let mut bytes = Vec::with_capacity(length / 2); + for chunk in hex.chunks(2) { + let msd = hex_value(chunk[0])?; + let lsd = hex_value(chunk[1])?; + bytes.push(msd << 4 | lsd); + } + + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[std::prelude::v1::test] + fn test_hex_decode() { + assert_eq!(hex_decode("deadf00d"), Ok(vec![0xde, 0xad, 0xf0, 0x0d])); + assert_eq!(hex_decode("0Xdeadf00d"), Ok(vec![0xde, 0xad, 0xf0, 0x0d])); + assert_eq!(hex_decode("0xdeadf00d"), Ok(vec![0xde, 0xad, 0xf0, 0x0d])); + + assert_eq!(hex_decode(""), Err(HexError::InvalidStringLength(0))); + assert_eq!(hex_decode("f00"), Err(HexError::InvalidStringLength(3))); + assert_eq!(hex_decode("0xf00"), Err(HexError::InvalidStringLength(5))); + + assert_eq!(hex_decode("0x"), Err(HexError::InvalidCharacter(120))); + assert_eq!(hex_decode("0X"), Err(HexError::InvalidCharacter(88))); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 000000000..640b159f6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,281 @@ +#![allow(clippy::derive_partial_eq_without_eq)] +// oxide macro +#![allow(clippy::unnecessary_lazy_evaluations)] + +use crate::enterprise::grpc::WorkerState; +#[cfg(feature = "oauth")] +use crate::enterprise::handlers::oauth::{authorize, authorize_consent, refresh, token}; +#[cfg(feature = "worker")] +use crate::enterprise::handlers::worker::{create_job, job_status, list_workers, remove_worker}; +#[cfg(feature = "openid")] +use crate::enterprise::handlers::{ + openid_clients::{ + add_openid_client, change_openid_client, change_openid_client_state, delete_openid_client, + delete_user_app, get_openid_client, get_user_apps, list_openid_clients, update_user_app, + }, + openid_flow::{authentication_request, check_authorized, id_token, openid_configuration}, +}; +#[cfg(feature = "oauth")] +use crate::enterprise::oauth_state::OAuthState; +use crate::license::{Features, License}; +use appstate::AppState; +use config::DefGuardConfig; +use db::{init_db, AppEvent, DbPool, Device, GatewayEvent, WireguardNetwork}; +#[cfg(feature = "wireguard")] +use handlers::wireguard::{ + add_device, create_network, create_network_token, delete_device, delete_network, + download_config, get_device, list_devices, list_networks, list_user_devices, modify_device, + modify_network, network_details, network_stats, user_stats, +}; +use handlers::{ + auth::{ + authenticate, logout, mfa_disable, mfa_enable, totp_code, totp_disable, totp_enable, + totp_secret, web3auth_end, web3auth_start, webauthn_end, webauthn_finish, webauthn_init, + webauthn_start, + }, + group::{add_group_member, get_group, list_groups, remove_group_member}, + license::get_license, + settings::{get_settings, update_settings}, + user::{ + add_user, change_password, delete_security_key, delete_user, delete_wallet, get_user, + list_users, me, modify_user, set_wallet, update_wallet, username_available, + wallet_challenge, + }, + webhooks::{ + add_webhook, change_enabled, change_webhook, delete_webhook, get_webhook, list_webhooks, + }, +}; +use rocket::{config::Config, error::Error as RocketError, Build, Ignite, Rocket}; +use std::{ + net::{IpAddr, Ipv4Addr}, + sync::{Arc, Mutex}, +}; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; + +pub mod appstate; +pub mod auth; +pub mod config; +pub mod db; +pub mod enterprise; +mod error; +pub mod grpc; +pub mod handlers; +mod hex; +pub mod license; +#[cfg(feature = "oauth")] +pub mod oxide_auth_rocket; + +#[macro_use] +extern crate rocket; + +#[macro_use] +extern crate serde; + +pub async fn build_webapp( + config: DefGuardConfig, + webhook_tx: UnboundedSender, + webhook_rx: UnboundedReceiver, + wireguard_tx: UnboundedSender, + pool: DbPool, +) -> Rocket { + // configure Rocket webapp + let cfg = Config { + address: IpAddr::V4(Ipv4Addr::UNSPECIFIED), + port: config.http_port, + ..Config::default() + }; + let license_decoded = License::decode(&config.license); + let webapp = rocket::custom(cfg) + .mount( + "/api/v1", + routes![ + authenticate, + logout, + username_available, + list_users, + get_user, + add_user, + modify_user, + delete_user, + delete_security_key, + change_password, + wallet_challenge, + set_wallet, + update_wallet, + delete_wallet, + list_groups, + get_group, + me, + add_group_member, + remove_group_member, + get_license, + get_settings, + update_settings, + mfa_enable, + mfa_disable, + totp_secret, + totp_disable, + totp_enable, + totp_code, + webauthn_init, + webauthn_finish, + webauthn_start, + webauthn_end, + web3auth_start, + web3auth_end, + ], + ) + .mount( + "/api/v1/webhook", + routes![ + add_webhook, + list_webhooks, + get_webhook, + delete_webhook, + change_webhook, + change_enabled + ], + ); + #[cfg(feature = "wireguard")] + let webapp = webapp.mount( + "/api/v1", + routes![ + add_device, + get_device, + list_user_devices, + modify_device, + delete_device, + list_devices, + download_config, + ], + ); + // initialize webapp with network routes + #[cfg(feature = "wireguard")] + let webapp = webapp.mount( + "/api/v1/network", + routes![ + create_network, + delete_network, + modify_network, + list_networks, + network_details, + create_network_token, + user_stats, + network_stats, + ], + ); + #[cfg(feature = "openid")] + let webapp = if license_decoded.validate(&Features::Openid) { + info!("Openid feature is enabled"); + webapp.mount( + "/api/v1/openid", + routes![ + add_openid_client, + delete_openid_client, + change_openid_client, + list_openid_clients, + get_openid_client, + authentication_request, + id_token, + change_openid_client_state, + openid_configuration, + check_authorized, + update_user_app, + delete_user_app, + get_user_apps + ], + ) + } else { + webapp + }; + + // initialize OAuth2 + #[cfg(feature = "oauth")] + let webapp = if config.oauth_enabled && license_decoded.validate(&Features::Oauth) { + info!("OAuth2 feature is enabled"); + webapp.manage(OAuthState::new(pool.clone()).await).mount( + "/api/oauth", + routes![authorize, authorize_consent, token, refresh], + ) + } else { + webapp + }; + + webapp.manage( + AppState::new( + config, + pool, + webhook_tx, + webhook_rx, + wireguard_tx, + license_decoded, + ) + .await, + ) +} + +/// Runs core web server exposing REST API. +pub async fn run_web_server( + config: DefGuardConfig, + worker_state: Arc>, + webhook_tx: UnboundedSender, + webhook_rx: UnboundedReceiver, + wireguard_tx: UnboundedSender, + pool: DbPool, +) -> Result, RocketError> { + let webapp = build_webapp(config.clone(), webhook_tx, webhook_rx, wireguard_tx, pool).await; + let license_decoded = License::decode(&config.license); + #[cfg(feature = "worker")] + let webapp = if license_decoded.validate(&Features::Worker) { + info!("Worker feature is enabled"); + webapp.manage(worker_state).mount( + "/api/v1/worker", + routes![create_job, list_workers, job_status, remove_worker], + ) + } else { + webapp + }; + + info!("Started web services"); + webapp.launch().await +} + +/// Automates test objects creation to easily setup development environment. +/// Test network keys: +/// Public: zGMeVGm9HV9I4wSKF9AXmYnnAIhDySyqLMuKpcfIaQo= +/// Private: MAk3d5KuB167G88HM7nGYR6ksnPMAOguAg2s5EcPp1M= +/// Test device keys: +/// Public: gQYL5eMeFDj0R+lpC7oZyIl0/sNVmQDC6ckP7husZjc= +/// Private: wGS1qdJfYbWJsOUuP1IDgaJYpR+VaKZPVZvdmLjsH2Y= +pub async fn init_dev_env(config: &DefGuardConfig) { + log::debug!("Initializing dev environment"); + let pool = init_db( + &config.database_host, + config.database_port, + &config.database_name, + &config.database_user, + &config.database_password, + ) + .await; + let mut network = WireguardNetwork::new( + "TestNet".to_string(), + "10.1.1.1/24".parse().unwrap(), + 50051, + "0.0.0.0".to_string(), + None, + vec!["10.1.1.0/24".parse().unwrap()], + ) + .expect("Could not create network"); + network.pubkey = "zGMeVGm9HV9I4wSKF9AXmYnnAIhDySyqLMuKpcfIaQo=".to_string(); + network.prvkey = "MAk3d5KuB167G88HM7nGYR6ksnPMAOguAg2s5EcPp1M=".to_string(); + network.save(&pool).await.expect("Could not save network"); + + let mut device = Device::new( + "TestDevice".to_string(), + "10.1.1.10".to_string(), + "gQYL5eMeFDj0R+lpC7oZyIl0/sNVmQDC6ckP7husZjc=".to_string(), + 1, + ); + device.save(&pool).await.expect("Could not save device"); + log::info!("Dev environment initialized - TestNet, TestDevice added"); +} diff --git a/src/license.rs b/src/license.rs new file mode 100644 index 000000000..419bf35a8 --- /dev/null +++ b/src/license.rs @@ -0,0 +1,131 @@ +use base64; +use chrono::{NaiveDate, Utc}; +use rsa::{pkcs8::FromPublicKey, PaddingScheme, PublicKey, RsaPublicKey}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct License { + pub company: String, + pub expiration: NaiveDate, + pub ldap: bool, + pub openid: bool, + pub oauth: bool, + pub worker: bool, + pub enterprise: bool, +} + +#[cfg(feature = "mock-license-key")] +const PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoowOenhBJnaS5C/W9kHX +Vz6LQYUXczT1BasE+ehy53LWnj5nPD98J0/h3mUNrYcr28qKfj8MVNBDcvzRDCx2 +eVyXoEVffDLaMUU4rqNmIirOOm+Epwiln31Mwhi2G6RS+oHJsEprSoaZSa4GEtLk +YkzPAWoKLfQktwc6AeQp8p2Y+IUnVhIlkiVY+xyTMvMyRzcyFAG1t9fFdOuuCB2Q +vjkIF3OO93WiqSr13Un6U9kKz94p7JouXPBH3KlfbyNpXPkyFVzUD7b9cS8tnz9E +gKOzxk9Guyyj4IwwnBFCanSJR5bey3Cm3vi1QnwAVSQ5I8mqCHu75TfamIBWfVsI +jQIDAQAB +-----END PUBLIC KEY----- +"; + +#[cfg(not(feature = "mock-license-key"))] +const PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApI/JdghL3uSNqRbFwAv3 +s5tQQKfqL60srY6uaxng4dtpt0juWIhdzhoDEwUqJL8RA7mIRxJZ+FrgwrHm6Q7a +GI1TCKL+7QEjgNRlemtb9LeVo1eK3SVpV3UnXLAOTXnWXZanYcPYDp4MpflTUAIN +/iTCtjwn+0piSCXgj2qlmMiDQfTWcBQgSimDSYN1MXi74OczEnKtEt9WuMfluAib +t08etN/WX8S/FAiWicyL84Ol5htk1iLPwaP8FfAEvmpMY7obXATbBx+HNk8Zd1TU +1jbEqXTQn9RNLAZBwyMs4EeuuvzKgbOvsEyLTOEy9n7VtShG8X5VqFrPGuDmYTvS +7QIDAQAB +-----END PUBLIC KEY----- +"; + +#[derive(Serialize)] +pub enum Features { + Ldap, + Worker, + Openid, + Oauth, +} + +impl License { + #[must_use] + pub fn default() -> Self { + Self { + company: "community".into(), + expiration: NaiveDate::from_ymd(2100, 1, 1), + worker: false, + ldap: false, + oauth: false, + openid: false, + enterprise: false, + } + } + + fn get(&self, feature: &Features) -> bool { + match feature { + Features::Ldap => self.ldap, + Features::Openid => self.openid, + Features::Oauth => self.oauth, + Features::Worker => self.worker, + } + } + + pub fn validate(&self, feature: &Features) -> bool { + if self.expiration < Utc::now().naive_utc().date() { + info!("License expired"); + false + } else { + self.enterprise || self.get(feature) + } + } + + // Enterprise license enables all features. + fn sanitize(&mut self) { + if self.enterprise { + self.worker = true; + self.ldap = true; + self.oauth = true; + self.openid = true; + } + } + + // decode encoded license to struct to send to frontend + pub fn decode(license: &str) -> Self { + debug!("Checking license"); + if !license.is_empty() { + // Verify the signature. + let public_key = RsaPublicKey::from_public_key_pem(PUBLIC_KEY).unwrap(); + let padding = PaddingScheme::new_pkcs1v15_sign(None); + let license_decoded = match base64::decode(license) { + Ok(license_decoded) => license_decoded, + Err(e) => { + error!("Error decoding license. Using community features: {}", e); + return Self::default(); + } + }; + let len = license_decoded.len(); + if let Ok(()) = public_key.verify( + padding, + &license_decoded[..len - 256], + &license_decoded[len - 256..], + ) { + match bincode::deserialize::(&license_decoded[..]) { + Ok(mut license) => { + license.sanitize(); + info!("License validation successful: {:?}", license); + return license; + } + Err(e) => { + error!( + "Error deserializing license: {}. Using community features.", + e + ); + } + } + } else { + error!("Invalid license signature. Using community features"); + } + } else { + info!("No license supplied. Using community features"); + } + Self::default() + } +} diff --git a/src/oxide_auth_rocket/failure.rs b/src/oxide_auth_rocket/failure.rs new file mode 100644 index 000000000..e8f9be46b --- /dev/null +++ b/src/oxide_auth_rocket/failure.rs @@ -0,0 +1,67 @@ +use self::{ + Kind::{OAuth, Web}, + OAuthError::{BadRequest, DenySilently, PrimitiveError}, +}; +use super::WebError; +use oxide_auth::endpoint::OAuthError; +use rocket::{ + http::Status, + response::{Responder, Result}, + Request, +}; + +/// Failed handling of an oauth request, providing a response. +/// +/// The error responses generated by this type are *not* part of the stable interface. To create +/// stable error pages or to build more meaningful errors, either destructure this using the +/// `oauth` and `web` method or avoid turning errors into this type by providing a custom error +/// representation. +#[derive(Clone, Debug)] +pub struct OAuthFailure { + inner: Kind, +} + +// impl OAuthFailure { +// /// Get the `OAuthError` causing this failure. +// pub fn oauth(&self) -> Option { +// match &self.inner { +// OAuth(err) => Some(*err), +// _ => None, +// } +// } + +// /// Get the `WebError` causing this failure. +// pub fn web(&self) -> Option { +// match &self.inner { +// Web(err) => Some(*err), +// _ => None, +// } +// } +// } + +#[derive(Clone, Debug)] +enum Kind { + Web(WebError), + OAuth(OAuthError), +} + +impl<'r> Responder<'r, 'static> for OAuthFailure { + fn respond_to(self, _: &Request) -> Result<'static> { + match self.inner { + Web(_) | OAuth(DenySilently | BadRequest) => Err(Status::BadRequest), + OAuth(PrimitiveError) => Err(Status::InternalServerError), + } + } +} + +impl From for OAuthFailure { + fn from(err: OAuthError) -> Self { + OAuthFailure { inner: OAuth(err) } + } +} + +impl From for OAuthFailure { + fn from(err: WebError) -> Self { + OAuthFailure { inner: Web(err) } + } +} diff --git a/src/oxide_auth_rocket/mod.rs b/src/oxide_auth_rocket/mod.rs new file mode 100644 index 000000000..e6eb2ee6a --- /dev/null +++ b/src/oxide_auth_rocket/mod.rs @@ -0,0 +1,227 @@ +//! This is a copy of that crate +//! https://github.com/HeroicKatora/oxide-auth/tree/master/oxide-auth-rocket +//! with the following pull request applied +//! https://github.com/HeroicKatora/oxide-auth/pull/132 + +mod failure; + +use std::{io::Cursor, marker::PhantomData}; + +use rocket::{ + data::ToByteUnit, + http::{ContentType, Header, Status}, + outcome::Outcome, + request::FromRequest, + response::{self, Responder}, + {uri, Data, Request, Response}, +}; + +use oxide_auth::{ + endpoint::{NormalizedParameter, WebRequest, WebResponse}, + frontends::dev::{Cow, QueryParameter, Url}, +}; + +pub use self::failure::OAuthFailure; +pub use oxide_auth::frontends::simple::{endpoint::Generic, request::NoError}; + +/// Request guard that also buffers OAuth data internally. +pub struct OAuthRequest<'r> { + auth: Option, + pub query: Result, + body: Result, WebError>, + lifetime: PhantomData<&'r ()>, +} + +/// Response type for Rocket OAuth requests +/// +/// A simple wrapper type around a simple `rocket::Response<'r>` that implements `WebResponse`. +#[derive(Debug, Default)] +pub struct OAuthResponse<'r>(Response<'r>); + +/// Request error at the http layer. +/// +/// For performance and consistency reasons, the processing of a request body and data is delayed +/// until it is actually required. This in turn means that some invalid requests will only be +/// caught during the OAuth process. The possible errors are collected in this type. +#[derive(Clone, Copy, Debug)] +pub enum WebError { + /// A parameter was encoded incorrectly. + /// + /// This may happen for example due to a query parameter that is not valid utf8 when the query + /// parameters are necessary for OAuth processing. + Encoding, + /// The body was needed but not provided. + BodyNeeded, + /// Form data was requested but the request was not a form. + NotAForm, +} + +impl<'r> OAuthRequest<'r> { + /// Create the request data from request headers. + /// + /// Some oauth methods need additionally the body data which you can attach later. + #[must_use] + pub fn new(request: &Request<'_>) -> Self { + let default_query_uri = uri!("?b"); + let default_query = default_query_uri.query().unwrap(); + let query = request.uri().query().unwrap_or(default_query); + let query = match serde_urlencoded::from_str(&query.to_string()) { + Ok(query) => Ok(query), + Err(_) => Err(WebError::Encoding), + }; + + let body = match request.content_type() { + Some(ct) if *ct == ContentType::Form => Ok(None), + _ => Err(WebError::NotAForm), + }; + + let mut all_auth = request.headers().get("Authorization"); + let optional = all_auth.next(); + + // Duplicate auth header, just treat it as no authorization. + let auth = if all_auth.next().is_some() { + None + } else { + optional.map(str::to_owned) + }; + + OAuthRequest { + auth, + query, + body, + lifetime: PhantomData, + } + } + + /// Provide the body of the request. + /// + /// Some, but not all operations, require reading their data from a urlencoded POST body. To + /// simplify the implementation of primitives and handlers, this type is the central request + /// type for both these use cases. When you forget to provide the body to a request, the oauth + /// system will return an error the moment the request is used. + pub async fn add_body(&mut self, data: Data<'_>) { + // Nothing to do if we already have a body, or already generated an error. This includes + // the case where the content type does not indicate a form, as the error is silent until a + // body is explicitely requested. + if let Ok(None) = self.body { + let data = data.open(2.mebibytes()).into_string().await; + match serde_urlencoded::from_str(&data.unwrap()) { + Ok(query) => self.body = Ok(Some(query)), + Err(_) => self.body = Err(WebError::Encoding), + } + } + } +} + +impl<'r> OAuthResponse<'r> { + /// Create a new `OAuthResponse<'r>` + #[must_use] + pub fn new() -> Self { + OAuthResponse::default() + } + + /// Create a new `OAuthResponse<'r>` from an existing `rocket::Response<'r>` + pub fn from_response(response: Response<'r>) -> Self { + OAuthResponse(response) + } +} + +impl<'r> WebRequest for OAuthRequest<'r> { + type Error = WebError; + type Response = OAuthResponse<'r>; + + fn query(&mut self) -> Result, Self::Error> { + match self.query.as_ref() { + Ok(query) => Ok(Cow::Borrowed(query as &dyn QueryParameter)), + Err(err) => Err(*err), + } + } + + fn urlbody(&mut self) -> Result, Self::Error> { + match self.body.as_ref() { + Ok(None) => Err(WebError::BodyNeeded), + Ok(Some(body)) => Ok(Cow::Borrowed(body as &dyn QueryParameter)), + Err(err) => Err(*err), + } + } + + fn authheader(&mut self) -> Result>, Self::Error> { + Ok(self.auth.as_deref().map(Cow::Borrowed)) + } +} + +impl<'r> WebResponse for OAuthResponse<'r> { + type Error = WebError; + + fn ok(&mut self) -> Result<(), Self::Error> { + self.0.set_status(Status::Ok); + Ok(()) + } + + fn redirect(&mut self, url: Url) -> Result<(), Self::Error> { + self.0.set_status(Status::Found); + self.0.set_header(Header::new("location", url.to_string())); + Ok(()) + } + + fn client_error(&mut self) -> Result<(), Self::Error> { + self.0.set_status(Status::BadRequest); + Ok(()) + } + + fn unauthorized(&mut self, kind: &str) -> Result<(), Self::Error> { + self.0.set_status(Status::Unauthorized); + self.0.set_raw_header("WWW-Authenticate", kind.to_owned()); + Ok(()) + } + + fn body_text(&mut self, text: &str) -> Result<(), Self::Error> { + self.0 + .set_sized_body(text.len(), Cursor::new(text.to_owned())); + self.0.set_header(ContentType::Plain); + Ok(()) + } + + fn body_json(&mut self, data: &str) -> Result<(), Self::Error> { + self.0 + .set_sized_body(data.len(), Cursor::new(data.to_owned())); + self.0.set_header(ContentType::JSON); + Ok(()) + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for OAuthRequest<'r> { + type Error = NoError; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + Outcome::Success(Self::new(request)) + } +} + +impl<'r, 'o: 'r> Responder<'r, 'o> for OAuthResponse<'o> { + fn respond_to(self, _: &Request) -> response::Result<'o> { + Ok(self.0) + } +} + +impl<'r> Responder<'r, 'static> for WebError { + fn respond_to(self, _: &Request) -> response::Result<'static> { + match self { + WebError::Encoding | WebError::NotAForm => Err(Status::BadRequest), + WebError::BodyNeeded => Err(Status::InternalServerError), + } + } +} + +impl<'r> From> for OAuthResponse<'r> { + fn from(r: Response<'r>) -> Self { + OAuthResponse::from_response(r) + } +} + +impl<'r> From> for Response<'r> { + fn from(o: OAuthResponse<'r>) -> Self { + o.0 + } +} diff --git a/tests/auth.rs b/tests/auth.rs new file mode 100644 index 000000000..9524b96dd --- /dev/null +++ b/tests/auth.rs @@ -0,0 +1,229 @@ +use defguard::{ + auth::TOTP_CODE_VALIDITY_PERIOD, + build_webapp, + db::{AppEvent, GatewayEvent, User, UserInfo, Wallet}, + handlers::{Auth, AuthCode, AuthTotp}, +}; +use otpauth::TOTP; +use rocket::{http::Status, local::asynchronous::Client, serde::json::serde_json::json}; +use serde::Deserialize; +use std::time::SystemTime; +use tokio::sync::mpsc::unbounded_channel; +use webauthn_authenticator_rs::{prelude::Url, softpasskey::SoftPasskey, WebauthnAuthenticator}; +use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse}; + +mod common; +use common::init_test_db; + +async fn make_client() -> Client { + let (pool, config) = init_test_db().await; + + let mut user = User::new( + "hpotter".into(), + "pass123", + "Potter".into(), + "Harry".into(), + "h.potter@hogwart.edu.uk".into(), + None, + ); + user.save(&pool).await.unwrap(); + + let mut wallet = Wallet::new_for_user( + user.id.unwrap(), + "0x4aF8803CBAD86BA65ED347a3fbB3fb50e96eDD3e".into(), + "test".into(), + 5, + String::new(), + ); + wallet.save(&pool).await.unwrap(); + + let (tx, rx) = unbounded_channel::(); + let (wg_tx, _) = unbounded_channel::(); + + let webapp = build_webapp(config, tx, rx, wg_tx, pool).await; + Client::tracked(webapp).await.unwrap() +} + +#[rocket::async_test] +async fn test_logout() { + let client = make_client().await; + + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client.get("/api/v1/me").dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client.post("/api/v1/auth/logout").dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client.get("/api/v1/me").dispatch().await; + assert_eq!(response.status(), Status::Unauthorized); +} + +#[rocket::async_test] +async fn test_totp() { + let client = make_client().await; + + fn totp_code(auth_totp: &AuthTotp) -> AuthCode { + let auth = TOTP::from_base32(auth_totp.secret.clone()).unwrap(); + let timestamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + AuthCode::new(auth.generate(TOTP_CODE_VALIDITY_PERIOD, timestamp)) + } + + // login + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // new TOTP secret + let response = client.post("/api/v1/auth/totp/init").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let auth_totp: AuthTotp = response.into_json().await.unwrap(); + + // enable TOTP + let code = totp_code(&auth_totp); + let response = client.put("/api/v1/auth/totp").json(&code).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // enable MFA + let response = client.post("/api/v1/auth/mfa").dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // logout + let response = client.post("/api/v1/auth/logout").dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // login again, this time a different status code is returned + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Created); + + // still unauthorized + let response = client.get("/api/v1/me").dispatch().await; + assert_eq!(response.status(), Status::Unauthorized); + + // provide wrong TOTP code + let code = AuthCode::new(0); + let response = client + .post("/api/v1/auth/totp") + .json(&code) + .dispatch() + .await; + assert_eq!(response.status(), Status::Unauthorized); + + // provide correct TOTP code + let code = totp_code(&auth_totp); + let response = client + .post("/api/v1/auth/totp") + .json(&code) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + // authorized + let response = client.get("/api/v1/me").dispatch().await; + assert_eq!(response.status(), Status::Ok); +} + +#[rocket::async_test] +async fn test_webauthn() { + let client = make_client().await; + + let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new()); + let origin = Url::parse("http://localhost:8080").unwrap(); + + // login + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // WebAuthn registration + let response = client.post("/api/v1/auth/webauthn/init").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let ccr: CreationChallengeResponse = response.into_json().await.unwrap(); + let rpkc = authenticator.do_registration(origin.clone(), ccr).unwrap(); + let response = client + .post("/api/v1/auth/webauthn/finish") + .json(&json!({ + "name": "My security key", + "rpkc": &rpkc + })) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + // WebAuthn authentication + let response = client.post("/api/v1/auth/webauthn/start").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let rcr: RequestChallengeResponse = response.into_json().await.unwrap(); + let pkc = authenticator.do_authentication(origin, rcr).unwrap(); + let response = client + .post("/api/v1/auth/webauthn") + .json(&pkc) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + // get security keys + let response = client.get("/api/v1/me").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let user_info: UserInfo = response.into_json().await.unwrap(); + assert_eq!(user_info.security_keys.len(), 1); + + // delete security key + let response = client + .delete(format!( + "/api/v1/user/hpotter/security_key/{}", + user_info.security_keys[0].id + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); +} + +#[rocket::async_test] +async fn test_web3() { + let client = make_client().await; + + // login + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client + .put("/api/v1/user/hpotter/wallet/0x4aF8803CBAD86BA65ED347a3fbB3fb50e96eDD3e") + .json(&json!({ + "use_for_mfa": true + })) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + #[derive(Deserialize)] + struct Challenge { + challenge: String, + } + + // Web3 authentication + let response = client.post("/api/v1/auth/web3/start").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let data: Challenge = response.into_json().await.unwrap(); + assert_eq!( + data.challenge, + "By signing this message you confirm that you're the owner of the wallet" + ); + + let response = client + .post("/api/v1/auth/web3") + .json(&json!({ + "address": "0x4aF8803CBAD86BA65ED347a3fbB3fb50e96eDD3e", + "signature": "0xcf9a650ed3dbb594f68a0614fc385363f17a150f0ced6e0e92f6cc40923ec0d86c70aa3a74e73216a57d6ae6a1e07e5951416491a2660a88d5d78a5ec7e4a9bd1c" + })) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 000000000..1d1260280 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,42 @@ +use clap::Parser; +use defguard::{ + config::DefGuardConfig, + db::{init_db, DbPool}, +}; +use sqlx::{postgres::PgConnectOptions, query, types::Uuid}; + +pub(super) async fn init_test_db() -> (DbPool, DefGuardConfig) { + let config = DefGuardConfig::parse(); + let opts = PgConnectOptions::new() + .host(&config.database_host) + .port(config.database_port) + .username(&config.database_user) + .password(&config.database_password) + .database(&config.database_name); + let pool = DbPool::connect_with(opts) + .await + .expect("Failed to connect to Postgres"); + let db_name = Uuid::new_v4().to_string(); + query(&format!("CREATE DATABASE \"{}\"", db_name)) + .execute(&pool) + .await + .expect("Failed to create test database"); + let pool = init_db( + &config.database_host, + config.database_port, + &db_name, + &config.database_user, + &config.database_password, + ) + .await; + (pool, config) +} + +#[allow(dead_code)] +#[cfg(feature = "oauth")] +pub(super) static LICENSE_ENTERPRISE: &str = "BwAAAAAAAAB0ZW9uaXRlCgAAAAAAAAAyMDUwLTEwLTEwAAAAAAFiayfBptq8pZXjPo4FV3VnmmwR/ipZHLriVPTW3AFyRq4c2wR+DzWC4BUACu3YMS27kX116JVKWB3/edYKNELFSiqYc6vsfoOrXnnQQJDI8RoyAQB6MpLv/EcgRZh47iI4L+tp44jKFQZ+EqqvMNt3G41u13P72HdkUv8yzQ7dmm3BrYQGJSCh/xiLna+mtQ9IQdqXOmYVInPXiWtIvi157Utfnow3gS0Ak45jci0DhtH+RWmFfiMOQCc4Qx0kEF9PsHl6Hn9Ay4oRTAnSYEPdWfQlVh5Rp276bLqnHDdyJ3/o2RSNK+QUXR7V2iuN1M3sWyW1rCGXtV5miHGI97CS"; +#[allow(dead_code)] +pub(super) static LICENSE_EXPIRED: &str = "BwAAAAAAAAB0ZW9uaXRlCgAAAAAAAAAyMDE5LTEwLTEwAAAAAAFuZ7Xm9M20ds/U/PQgVmz4uViCRTJbyAPVLtYRBGvE0i+czH4mxPl4mCyAO1cAOPXNxqh9sAVVr/GzToOix4DfK0aLrYG9FqV5jW13CH+UKTFBqQvN9gGLmnl9+b3pH10gxpGKRZ5fn73fsZsO0SKrJvQ8SAHEQ2+r+VCdZphZ2r9cFR6MC39Ixk4lCki8mz9A4FHZyW4YWWr6k+bxu9RjG/0imh+6OBeddKBpU3HnK96B4rjhiEhrKpfJo6dzib/Mfk+UNZHQA2dAjlocKKxa2+acUaEJQmnaIv4FyFZHl2OzGKkweqDBo0E+Ai7m1g07+pXdXGYb9ykVfoCBEgEX"; +#[allow(dead_code)] +#[cfg(feature = "oauth")] +pub(super) static LICENSE_WITHOUT_OPENID: &str = "BwAAAAAAAAB0ZW9uaXRlCgAAAAAAAAAyMDUwLTEwLTIyAQABAQCCpzpcqi+8jRX+QTuVjyK0ZmdKa8j+SrA53qSY4rAxjZyt6hgVLlcqTqIbbA7uds5ACa1oBWvQbbPIlGGTpNnG+gQzTm9hAc3CmEd2zMdQXOXzWN8jJHTflsr1dYMxA+tK1el+An+jOY85j0WaRNJma7desF6HEasgEEPktV5P5y3Yh1fULS1scDjEbOJS3pvI07BmSA0/Z+swPMqRzSoyt6NaOUDbR53HR2mMjBSsaZBLsrTQ9Ai16A8fo6pqt2XpSfy/1ImC3mq2q6TG/ABnFw1j65UW0Mx261Bn9184zyLdKPycFUWfyOmOpk/46JZX/PMBHERXeFbmN6YE3KpO"; diff --git a/tests/license.rs b/tests/license.rs new file mode 100644 index 000000000..67362464b --- /dev/null +++ b/tests/license.rs @@ -0,0 +1,127 @@ +use defguard::enterprise::grpc::WorkerState; +#[cfg(feature = "worker")] +use defguard::enterprise::handlers::worker::{create_job, job_status, list_workers, remove_worker}; +use defguard::{ + build_webapp, + db::{AppEvent, GatewayEvent}, + handlers::Auth, + license::{Features, License}, +}; +use rocket::{http::Status, local::asynchronous::Client, routes}; +use std::{ + env, + sync::{Arc, Mutex}, +}; +use tokio::sync::mpsc::unbounded_channel; + +mod common; +use common::{init_test_db, LICENSE_ENTERPRISE, LICENSE_EXPIRED, LICENSE_WITHOUT_OPENID}; + +async fn make_client(license: &str) -> Client { + env::set_var("DEFGUARD_OAUTH_ENABLED", "true"); + let (pool, mut config) = init_test_db().await; + config.license = license.into(); + + let (tx, rx) = unbounded_channel::(); + let (wg_tx, _) = unbounded_channel::(); + let (webhook_tx, _webhook_rx) = unbounded_channel::(); + let webapp = build_webapp(config.clone(), tx, rx, wg_tx, pool).await; + + let worker_state = Arc::new(Mutex::new(WorkerState::new(webhook_tx.clone()))); + let license_decoded = License::decode(license); + #[cfg(feature = "worker")] + let webapp = if license_decoded.validate(&Features::Worker) { + webapp.manage(worker_state).mount( + "/api/v1/worker", + routes![create_job, list_workers, job_status, remove_worker], + ) + } else { + webapp + }; + let client = Client::tracked(webapp).await.unwrap(); + { + let auth = Auth::new("admin".into(), "pass123".into()); + let response = &client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + } + client +} + +#[cfg(feature = "oauth")] +#[rocket::async_test] +async fn test_license_ok() { + let client = make_client(LICENSE_ENTERPRISE).await; + + // Check if openid path exist + let response = client.get("/api/v1/openid").dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // check if worker path exist + let response = client.get("/api/v1/worker").dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client + .get( + "/api/oauth/authorize?\ + response_type=code&\ + client_id=LocalClient&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=default-scope&\ + state=ABCDEF", + ) + .dispatch() + .await; + + assert_eq!(response.status(), Status::Found); +} + +#[rocket::async_test] +async fn test_license_expired() { + // test expired license + let client = make_client(LICENSE_EXPIRED).await; + + let response = client.get("/api/v1/openid").dispatch().await; + assert_eq!(response.status(), Status::NotFound); + + let response = client.get("/api/v1/worker").dispatch().await; + assert_eq!(response.status(), Status::NotFound); + + let response = client + .get( + "/api/oauth/authorize?\ + response_type=code&\ + client_id=LocalClient&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=default-scope&\ + state=ABCDEF", + ) + .dispatch() + .await; + assert_eq!(response.status(), Status::NotFound); +} + +#[cfg(feature = "oauth")] +#[rocket::async_test] +async fn test_license_openid_disabled() { + // test expired license + let client = make_client(LICENSE_WITHOUT_OPENID).await; + + let response = client.get("/api/v1/openid").dispatch().await; + assert_eq!(response.status(), Status::NotFound); + + let response = client.get("/api/v1/worker").dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client + .get( + "/api/oauth/authorize?\ + response_type=code&\ + client_id=LocalClient&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=default-scope&\ + state=ABCDEF", + ) + .dispatch() + .await; + assert_eq!(response.status(), Status::Found); +} diff --git a/tests/oauth.rs b/tests/oauth.rs new file mode 100644 index 000000000..824b9caec --- /dev/null +++ b/tests/oauth.rs @@ -0,0 +1,102 @@ +use defguard::{ + enterprise::handlers::oauth::{authorize, authorize_consent, refresh, token}, + enterprise::oauth_state::OAuthState, +}; +use rocket::{ + http::{ContentType, Header, Status}, + local::asynchronous::Client, + routes, +}; + +mod common; +use common::init_test_db; + +async fn make_client() -> Client { + let (pool, _config) = init_test_db().await; + let webapp = rocket::build().manage(OAuthState::new(pool).await).mount( + "/api/oauth", + routes![authorize, authorize_consent, token, refresh], + ); + Client::tracked(webapp).await.unwrap() +} + +#[rocket::async_test] +async fn test_authorize() { + let client = make_client().await; + + let response = client + .get( + "/api/oauth/authorize?\ + response_type=code&\ + client_id=LocalClient&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=default-scope&\ + state=ABCDEF", + ) + .dispatch() + .await; + assert_eq!(response.status(), Status::Found); +} + +#[rocket::async_test] +async fn test_authorize_consent() { + let client = make_client().await; + + let response = client + .post( + "/api/oauth/authorize?\ + allow=true&\ + response_type=code&\ + client_id=LocalClient&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=default-scope&\ + state=ABCDEF", + ) + .dispatch() + .await; + assert_eq!(response.status(), Status::Found); + + let localtion = response.headers().get_one("Location").unwrap(); + assert!(localtion.starts_with("http://localhost:3000/?code=")); + + // extract code + let index = localtion.find("&state").unwrap(); + let code = localtion.get(28..index).unwrap(); + + let response = client + .post("/api/oauth/token") + .header(ContentType::Form) + .header(Header::new( + "Authorization", + // echo -n 'LocalClient:secret' | base64 + "Basic TG9jYWxDbGllbnQ6c2VjcmV0", + )) + .body(format!( + "grant_type=authorization_code&\ + code={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F", + code + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); +} + +#[rocket::async_test] +async fn test_authorize_consent_wrong_client() { + let client = make_client().await; + + let response = client + .post( + "/api/oauth/authorize?\ + allow=true&\ + response_type=code&\ + client_id=NonExistentClient&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=default-scope&\ + state=ABCDEF", + ) + .dispatch() + .await; + assert_eq!(response.status(), Status::BadRequest); +} diff --git a/tests/openid.rs b/tests/openid.rs new file mode 100644 index 000000000..9718d4b82 --- /dev/null +++ b/tests/openid.rs @@ -0,0 +1,396 @@ +use defguard::{ + build_webapp, + db::{AppEvent, GatewayEvent}, + enterprise::db::openid::{AuthorizedApp, NewOpenIDClient, OpenIDClient}, + handlers::Auth, +}; +use rocket::{ + http::{ContentType, Status}, + local::asynchronous::Client, +}; +use tokio::sync::mpsc::unbounded_channel; + +mod common; +use common::{init_test_db, LICENSE_ENTERPRISE}; + +async fn make_client() -> Client { + let (pool, mut config) = init_test_db().await; + config.license = LICENSE_ENTERPRISE.into(); + + let (tx, rx) = unbounded_channel::(); + let (wg_tx, _) = unbounded_channel::(); + + let webapp = build_webapp(config, tx, rx, wg_tx, pool).await; + Client::tracked(webapp).await.unwrap() +} + +#[rocket::async_test] +async fn test_openid_client() { + let client = make_client().await; + + let auth = Auth::new("admin".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let mut openid_client = NewOpenIDClient { + name: "Test".into(), + description: "Test".into(), + home_url: "http://localhost:3000".into(), + redirect_uri: "http://localhost:3000/".into(), + enabled: true, + }; + + let response = client + .post("/api/v1/openid") + .json(&openid_client) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + + let response = client.get("/api/v1/openid").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let openid_clients: Vec = response.into_json().await.unwrap(); + assert_eq!(openid_clients.len(), 1); + + openid_client.description = "Changed".into(); + openid_client.name = "Test changed".into(); + let response = client + .put(format!("/api/v1/openid/{}", openid_clients[0].id.unwrap())) + .json(&openid_client) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + let response = client + .get(format!("/api/v1/openid/{}", openid_clients[0].id.unwrap())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let fetched_client: OpenIDClient = response.into_json().await.unwrap(); + assert_eq!(fetched_client.home_url, openid_client.home_url); + assert_eq!(fetched_client.description, openid_client.description); + assert_eq!(fetched_client.name, openid_client.name); + + // Openid flow tests + // test unsupported response type + // Test client delete + let response = client + .delete(format!("/api/v1/openid/{}", openid_clients[0].id.unwrap())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + let response = client.get("/api/v1/openid").dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let openid_clients: Vec = response.into_json().await.unwrap(); + assert!(openid_clients.is_empty()); +} + +#[rocket::async_test] +async fn test_openid_flow() { + let client = make_client().await; + let auth = Auth::new("admin".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + let openid_client = NewOpenIDClient { + name: "Test".into(), + description: "Test".into(), + home_url: "http://localhost:3000".into(), + redirect_uri: "http://localhost:3000/".into(), + enabled: true, + }; + + let response = client + .post("/api/v1/openid") + .json(&openid_client) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + let openid_client: OpenIDClient = response.into_json().await.unwrap(); + assert_eq!(openid_client.name, "Test"); + + // all clients + let response = client.get("/api/v1/openid").dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client + .post(format!( + "/api/v1/openid/authorize?\ + response_type=code%20id_token%20token&\ + client_id={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=openid&\ + state=ABCDEF&\ + allow=true&\ + nonce=blabla", + openid_client.client_id + )) + .dispatch() + .await; + let location = response.headers().get_one("Location").unwrap(); + assert!(location.contains("error=unsupported_response_type")); + + // unsupported_response_type + let response = client + .post(format!( + "/api/v1/openid/authorize?\ + response_type=code%20id_token%20token&\ + client_id={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=openid&\ + state=ABCDEF&\ + allow=true&\ + nonce=blabla", + openid_client.client_id + )) + .dispatch() + .await; + let location = response.headers().get_one("Location").unwrap(); + assert!(location.contains("error=unsupported_response_type")); + + let response = client + .post(format!( + "/api/v1/openid/authorize?\ + response_type=id_token&\ + client_id={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=openid&\ + state=ABCDEF&\ + allow=true&\ + nonce=blabla", + openid_client.client_id + )) + .dispatch() + .await; + let location = response.headers().get_one("Location").unwrap(); + assert!(location.contains("error=unsupported_response_type")); + + // Obtain code + let response = client + .post(format!( + "/api/v1/openid/authorize?\ + response_type=code&\ + client_id={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=openid&\ + state=ABCDEF&\ + allow=true&\ + nonce=blabla", + openid_client.client_id + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::Found); + + let location = response.headers().get_one("Location").unwrap(); + assert!(location.starts_with("http://localhost:3000/?code=")); + + // check returned state + let index = location.find("&state").unwrap(); + assert_eq!("&state=ABCDEF", location.get(index..).unwrap()); + // exchange wrong code for token should fail + let response = client + .post("/api/v1/openid/token") + .header(ContentType::Form) + .body( + "grant_type=authorization_code&\ + code=ncuoew2323&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F", + ) + .dispatch() + .await; + assert_eq!(response.status(), Status::BadRequest); + + // exchange code for token + let code = location.get(28..index).unwrap(); + let response = client + .post("/api/v1/openid/token") + .header(ContentType::Form) + .body(format!( + "grant_type=authorization_code&\ + code={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F", + code + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + // check used code + let response = client + .post("/api/v1/openid/token") + .header(ContentType::Form) + .body(format!( + "grant_type=authorization_code&\ + code={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F", + code + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::BadRequest); + + // test non-existing client + let response = client + .post( + "/api/v1/openid/authorize?\ + response_type=code&\ + client_id=666&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=openid&\ + state=ABCDEF&\ + nonce=blabla", + ) + .dispatch() + .await; + let location = response.headers().get_one("Location").unwrap(); + assert!(location.contains("error")); + + // test wrong redirect uri + let response = client + .post( + "/api/v1/openid/authorize?\ + response_type=code&\ + client_id=1&\ + redirect_uri=http%3A%2F%example%3A3000%2F&\ + scope=openid&\ + state=ABCDEF&\ + nonce=blabla", + ) + .dispatch() + .await; + let location = response.headers().get_one("Location").unwrap(); + assert!(location.contains("error")); + + // test scope doesnt contain openid + let response = client + .post(format!( + "/api/v1/openid/authorize?\ + response_type=code&\ + client_id={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=blabla&\ + state=ABCDEF&\ + allow=true&\ + nonce=blabla", + openid_client.client_id + )) + .dispatch() + .await; + let location = response.headers().get_one("Location").unwrap(); + assert!(location.contains("error=wrong_scope&error_description=scope_must_contain_openid")); + + // test allow false + let response = client + .post(format!( + "/api/v1/openid/authorize?\ + response_type=code&\ + client_id={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=blabla&\ + state=ABCDEF&\ + allow=false&\ + nonce=blabla", + openid_client.client_id + )) + .dispatch() + .await; + let location = response.headers().get_one("Location").unwrap(); + assert!(location.contains("error=user_unauthorized")); +} + +#[rocket::async_test] +async fn test_openid_apps() { + let client = make_client().await; + + let auth = Auth::new("admin".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let openid_client = NewOpenIDClient { + name: "Test".into(), + description: "Test".into(), + home_url: "http://localhost:3000".into(), + redirect_uri: "http://localhost:3000/".into(), + enabled: true, + }; + let response = client + .post("/api/v1/openid") + .json(&openid_client) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + let fetched_client: OpenIDClient = response.into_json().await.unwrap(); + assert_eq!(fetched_client.name, "Test"); + + let response = client + .post(format!( + "/api/v1/openid/authorize?\ + response_type=code&\ + client_id={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F&\ + scope=openid&\ + state=ABCDEF&\ + allow=true&\ + nonce=blabla", + fetched_client.client_id + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::Found); + + let location = response.headers().get_one("Location").unwrap(); + let index = location.find("&state").unwrap(); + let code = location.get(28..index).unwrap(); + let response = client + .post("/api/v1/openid/token") + .header(ContentType::Form) + .body(format!( + "grant_type=authorization_code&\ + code={}&\ + redirect_uri=http%3A%2F%2Flocalhost%3A3000%2F", + code + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + // fetch applications + let response = client.get("/api/v1/openid/apps/admin").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let mut apps: Vec = response.into_json().await.unwrap(); + assert_eq!(apps.len(), 1); + + let mut app = apps.pop().unwrap(); + assert_eq!(app.name, "Test"); + + // rename application + app.name = "My app".into(); + let response = client + .put(format!("/api/v1/openid/apps/{}", app.id.unwrap())) + .json(&app) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + // fetch again to check if the name has been changed + let response = client.get("/api/v1/openid/apps/admin").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let apps: Vec = response.into_json().await.unwrap(); + assert_eq!(apps.len(), 1); + assert_eq!(apps[0].name, "My app"); + + // delete application + let response = client + .delete(format!("/api/v1/openid/apps/{}", app.id.unwrap())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + // fetch once more to check if the application has been deleted + let response = client.get("/api/v1/openid/apps/admin").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let apps: Vec = response.into_json().await.unwrap(); + assert_eq!(apps.len(), 0); +} diff --git a/tests/settings.rs b/tests/settings.rs new file mode 100644 index 000000000..7100fe566 --- /dev/null +++ b/tests/settings.rs @@ -0,0 +1,66 @@ +use defguard::{ + build_webapp, + db::{models::settings::Settings, AppEvent, GatewayEvent}, + handlers::Auth, +}; +use rocket::{http::Status, local::asynchronous::Client}; +use tokio::sync::mpsc::unbounded_channel; + +mod common; +use common::init_test_db; + +async fn make_client() -> Client { + let (pool, config) = init_test_db().await; + + let (tx, rx) = unbounded_channel::(); + let (wg_tx, _) = unbounded_channel::(); + + let webapp = build_webapp(config, tx, rx, wg_tx, pool).await; + Client::tracked(webapp).await.unwrap() +} + +#[rocket::async_test] +async fn test_settings() { + let client = make_client().await; + + let auth = Auth::new("admin".into(), "pass123".into()); + let response = &client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // get settings + let response = client.get("/api/v1/settings").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let mut settings: Settings = response.into_json().await.unwrap(); + assert_eq!( + settings, + Settings { + id: None, + web3_enabled: true, + openid_enabled: true, + oauth_enabled: true, + ldap_enabled: true, + wireguard_enabled: true, + webhooks_enabled: true, + worker_enabled: true, + challenge_template: + "By signing this message you confirm that you're the owner of the wallet" + .to_string(), + } + ); + + // modify settings + settings.wireguard_enabled = false; + settings.challenge_template = "Modified".to_string(); + let response = client + .put("/api/v1/settings") + .json(&settings) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + // verify modified settings + let response = client.get("/api/v1/settings").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let new_settings: Settings = response.into_json().await.unwrap(); + assert_eq!(new_settings, settings); +} diff --git a/tests/user.rs b/tests/user.rs new file mode 100644 index 000000000..044f99210 --- /dev/null +++ b/tests/user.rs @@ -0,0 +1,304 @@ +use defguard::{ + build_webapp, + db::{AppEvent, GatewayEvent, User, UserInfo}, + handlers::{AddUserData, Auth, PasswordChange, Username, WalletChallenge}, +}; +use rocket::{http::Status, local::asynchronous::Client, serde::json::serde_json::json}; +use tokio::sync::mpsc::unbounded_channel; + +mod common; +use common::init_test_db; + +async fn make_client() -> Client { + let (pool, config) = init_test_db().await; + + let mut user = User::new( + "hpotter".into(), + "pass123", + "Potter".into(), + "Harry".into(), + "h.potter@hogwart.edu.uk".into(), + None, + ); + user.save(&pool).await.unwrap(); + + let (tx, rx) = unbounded_channel::(); + let (wg_tx, _) = unbounded_channel::(); + + let webapp = build_webapp(config, tx, rx, wg_tx, pool).await; + Client::tracked(webapp).await.unwrap() +} + +#[rocket::async_test] +async fn test_authenticate() { + let client = make_client().await; + + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let auth = Auth::new("hpotter".into(), "-wrong-".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Unauthorized); + + let auth = Auth::new("adumbledore".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Unauthorized); +} + +#[rocket::async_test] +async fn test_me() { + let client = make_client().await; + + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client.get("/api/v1/me").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let user_info: UserInfo = response.into_json().await.unwrap(); + assert_eq!(user_info.first_name, "Harry"); + assert_eq!(user_info.last_name, "Potter"); +} + +#[rocket::async_test] +async fn test_change_password() { + let client = make_client().await; + + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let password = PasswordChange { + new_password: "lumos".into(), + }; + let response = client + .put("/api/v1/user/hpotter/password") + .json(&password) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Unauthorized); + + let auth = Auth::new("hpotter".into(), "lumos".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); +} + +#[rocket::async_test] +async fn test_list_users() { + let client = make_client().await; + + let response = client.get("/api/v1/user").dispatch().await; + assert_eq!(response.status(), Status::Unauthorized); + + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client.get("/api/v1/user").dispatch().await; + assert_eq!(response.status(), Status::Ok); +} + +#[rocket::async_test] +async fn test_get_user() { + let client = make_client().await; + + let response = client.get("/api/v1/user/hpotter").dispatch().await; + assert_eq!(response.status(), Status::Unauthorized); + + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client.get("/api/v1/user/hpotter").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let user_info: UserInfo = response.into_json().await.unwrap(); + assert_eq!(user_info.first_name, "Harry"); + assert_eq!(user_info.last_name, "Potter"); +} + +#[rocket::async_test] +async fn test_username_available() { + let client = make_client().await; + + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let avail = Username { + username: "CrashTestDummy".into(), + }; + let response = client + .post("/api/v1/user/available") + .json(&avail) + .dispatch() + .await; + assert_eq!(response.status(), Status::BadRequest); + + let avail = Username { + username: "crashtestdummy".into(), + }; + let response = client + .post("/api/v1/user/available") + .json(&avail) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + let avail = Username { + username: "hpotter".into(), + }; + let response = client + .post("/api/v1/user/available") + .json(&avail) + .dispatch() + .await; + assert_eq!(response.status(), Status::BadRequest); +} + +#[rocket::async_test] +async fn test_crud_user() { + let client = make_client().await; + + let auth = Auth::new("admin".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // create user + let new_user = AddUserData { + username: "adumbledore".into(), + last_name: "Dumbledore".into(), + first_name: "Albus".into(), + email: "a.dumbledore@hogwart.edu.uk".into(), + phone: "1234".into(), + password: "Alohomora!".into(), + }; + let response = client.post("/api/v1/user").json(&new_user).dispatch().await; + assert_eq!(response.status(), Status::Created); + + // get user + let response = client.get("/api/v1/user/adumbledore").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let mut user_info: UserInfo = response.into_json().await.unwrap(); + assert_eq!(user_info.first_name, "Albus"); + + // edit user + user_info.phone = Some("5678".into()); + let response = client + .put("/api/v1/user/adumbledore") + .json(&user_info) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + // delete user + let response = client.delete("/api/v1/user/adumbledore").dispatch().await; + assert_eq!(response.status(), Status::Ok); +} + +#[rocket::async_test] +async fn test_admin_group() { + let client = make_client().await; + + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client.get("/api/v1/group").dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let response = client.get("/api/v1/group/admin").dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // TODO: check group membership +} + +#[rocket::async_test] +async fn test_wallet() { + let client = make_client().await; + + let auth = Auth::new("hpotter".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let address = "0x4aF8803CBAD86BA65ED347a3fbB3fb50e96eDD3e"; + let challenge_query = + format!("/api/v1/user/hpotter/challenge?address={address}&name=portefeuille&chain_id=5"); + + // get challenge message + let response = client.get(challenge_query.clone()).dispatch().await; + assert_eq!(response.status(), Status::Ok); + let challenge: WalletChallenge = response.into_json().await.unwrap(); + // see migrations for the default message + assert_eq!( + challenge.message, + "By signing this message you confirm that you're the owner of the wallet" + ); + + let response = client.get("/api/v1/user/hpotter").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let user_info: UserInfo = response.into_json().await.unwrap(); + assert!(user_info.wallets.is_empty()); + + // send signature + let response = client + .put("/api/v1/user/hpotter/wallet") + .json(&json!({ + "address": address, + "signature": "0xcf9a650ed3dbb594f68a0614fc385363f17a150f0ced6e0e92f6cc40923ec0d86c70aa3a74e73216a57d6ae6a1e07e5951416491a2660a88d5d78a5ec7e4a9bd1c", + })) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + // get user info for wallets + let response = client.get("/api/v1/user/hpotter").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let user_info: UserInfo = response.into_json().await.unwrap(); + assert_eq!(user_info.wallets.len(), 1); + let wallet_info = &user_info.wallets[0]; + assert_eq!(wallet_info.address, address); + assert_eq!(wallet_info.name, "portefeuille"); + assert_eq!(wallet_info.chain_id, 5); + + // challenge must not be available for verified wallet addresses + let response = client.get(challenge_query).dispatch().await; + assert_eq!(response.status(), Status::NotFound); + + // delete wallet + let response = client + .delete(format!("/api/v1/user/hpotter/wallet/{address}")) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + let response = client.get("/api/v1/user/hpotter").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let user_info: UserInfo = response.into_json().await.unwrap(); + assert!(user_info.wallets.is_empty()); +} + +#[rocket::async_test] +async fn test_check_username() { + let client = make_client().await; + + let auth = Auth::new("admin".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // create user + let new_user = AddUserData { + username: "ADumbledore".into(), + last_name: "Dumbledore".into(), + first_name: "Albus".into(), + email: "a.dumbledore@hogwart.edu.uk".into(), + phone: "1234".into(), + password: "Alohomora!".into(), + }; + let response = client.post("/api/v1/user").json(&new_user).dispatch().await; + assert_eq!(response.status(), Status::BadRequest); +} diff --git a/tests/webhook.rs b/tests/webhook.rs new file mode 100644 index 000000000..245675b63 --- /dev/null +++ b/tests/webhook.rs @@ -0,0 +1,79 @@ +use defguard::{ + build_webapp, + db::{AppEvent, GatewayEvent, WebHook}, + handlers::Auth, +}; +use rocket::{http::Status, local::asynchronous::Client}; +use tokio::sync::mpsc::unbounded_channel; + +mod common; +use common::init_test_db; + +#[rocket::async_test] +async fn test_webhooks() { + let (pool, config) = init_test_db().await; + + let (tx, rx) = unbounded_channel::(); + let (wg_tx, _) = unbounded_channel::(); + + let webapp = build_webapp(config, tx, rx, wg_tx, pool).await; + let client = Client::tracked(webapp).await.unwrap(); + + let auth = Auth::new("admin".into(), "pass123".into()); + let response = client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + let mut webhook = WebHook { + id: None, + url: "http://localhost:3000/trigger-happy".into(), + description: "Test".into(), + token: "1234567890".into(), + enabled: false, + on_user_created: true, + on_user_deleted: false, + on_user_modified: true, + on_hwkey_provision: false, + }; + + let response = client + .post("/api/v1/webhook") + .json(&webhook) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + + let response = client.get("/api/v1/webhook").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let webhooks: Vec = response.into_json().await.unwrap(); + assert_eq!(webhooks.len(), 1); + + webhook.description = "Changed".into(); + webhook.on_user_modified = false; + let response = client + .put(format!("/api/v1/webhook/{}", webhooks[0].id.unwrap())) + .json(&webhook) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + let response = client + .get(format!("/api/v1/webhook/{}", webhooks[0].id.unwrap())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let fetched_webhook: WebHook = response.into_json().await.unwrap(); + assert_eq!(fetched_webhook.url, webhook.url); + assert_eq!(fetched_webhook.description, webhook.description); + assert_eq!(fetched_webhook.on_user_modified, webhook.on_user_modified); + + let response = client + .delete(format!("/api/v1/webhook/{}", webhooks[0].id.unwrap())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + + let response = client.get("/api/v1/webhook").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let webhooks: Vec = response.into_json().await.unwrap(); + assert!(webhooks.is_empty()); +} diff --git a/tests/wireguard.rs b/tests/wireguard.rs new file mode 100644 index 000000000..1c4d6dc78 --- /dev/null +++ b/tests/wireguard.rs @@ -0,0 +1,571 @@ +use chrono::{Datelike, Duration, NaiveDate, SubsecRound, Timelike, Utc}; +use defguard::{ + build_webapp, + config::DefGuardConfig, + db::{ + models::wireguard::{ + WireguardDeviceTransferRow, WireguardNetworkStats, WireguardUserStatsRow, + }, + AppEvent, DbPool, Device, GatewayEvent, WireguardNetwork, WireguardPeerStats, + }, + handlers::{wireguard::WireguardNetworkData, Auth}, +}; +use matches::assert_matches; +use rocket::{ + http::Status, + local::asynchronous::Client, + serde::json::{serde_json::json, Value}, +}; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; + +mod common; +use common::init_test_db; + +async fn make_client( + pool: DbPool, + config: DefGuardConfig, +) -> (Client, UnboundedReceiver) { + let (tx, rx) = unbounded_channel::(); + let (wg_tx, wg_rx) = unbounded_channel::(); + + let webapp = build_webapp(config, tx, rx, wg_tx, pool).await; + (Client::tracked(webapp).await.unwrap(), wg_rx) +} + +fn make_network() -> Value { + json!({ + "name": "network", + "address": "10.1.1.1/24", + "port": 55555, + "endpoint": "192.168.4.14", + "allowed_ips": "10.1.1.0/24", + "dns": "1.1.1.1", + }) +} + +#[rocket::async_test] +async fn test_network() { + let (pool, config) = init_test_db().await; + let (client, mut wg_rx) = make_client(pool, config).await; + + let auth = Auth::new("admin".into(), "pass123".into()); + let response = &client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // create network + let response = client + .post("/api/v1/network") + .json(&make_network()) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + let network: WireguardNetwork = response.into_json().await.unwrap(); + assert_eq!(network.name, "network"); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::NetworkCreated(_)); + + // modify network + let network_data = WireguardNetworkData { + name: "my network".into(), + address: "10.1.1.0/24".parse().unwrap(), + endpoint: "10.1.1.1".parse().unwrap(), + port: 55555, + allowed_ips: Some("10.1.1.0/24".into()), + dns: None, + }; + let response = client + .put(format!("/api/v1/network/{}", network.id.unwrap())) + .json(&network_data) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::NetworkModified(_)); + + // list networks + let response = client.get("/api/v1/network").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let networks: Vec = response.into_json().await.unwrap(); + assert_eq!(networks.len(), 1); + + // network details + let network_from_list = networks[0].clone(); + assert_eq!(network_from_list.name, "my network"); + let response = client + .get(format!("/api/v1/network/{}", network_from_list.id.unwrap())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let network_from_details: WireguardNetwork = response.into_json().await.unwrap(); + assert_eq!(network_from_details, network_from_list); + + // delete network + let response = client + .delete(format!("/api/v1/network/{}", network.id.unwrap())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::NetworkDeleted(_)); +} + +#[rocket::async_test] +async fn test_device() { + let (pool, config) = init_test_db().await; + let (client, mut wg_rx) = make_client(pool, config).await; + + let auth = Auth::new("admin".into(), "pass123".into()); + let response = &client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // create network + let response = client + .post("/api/v1/network") + .json(&make_network()) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::NetworkCreated(_)); + + // network details + let response = client.get("/api/v1/network/1").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let network_from_details: WireguardNetwork = response.into_json().await.unwrap(); + + // create device + let device = json!({ + "name": "device", + "wireguard_pubkey": "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=", + }); + let response = client + .post("/api/v1/device/admin") + .json(&device) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::DeviceCreated(_)); + + // list devices + let response = client.get("/api/v1/device").json(&device).dispatch().await; + assert_eq!(response.status(), Status::Ok); + let devices: Vec = response.into_json().await.unwrap(); + assert_eq!(devices.len(), 1); + let device = devices[0].clone(); + assert_eq!(device.name, "device"); + assert_eq!( + device.wireguard_pubkey, + "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=" + ); + + // list user devices + let response = client + .get("/api/v1/device/user/admin") + .json(&device) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let user_devices: Vec = response.into_json().await.unwrap(); + assert_eq!(user_devices.len(), 1); + assert_eq!(devices.len(), 1); + assert_eq!(device.id, user_devices[0].id); + + // modify device + let modified_name = "modified-device"; + let modified_key = "sIhx53MsX+iLk83sssybHrD7M+5m+CmpLzWL/zo8C38="; + let mut modified_device = device.clone(); + modified_device.name = modified_name.into(); + modified_device.wireguard_pubkey = modified_key.into(); + let response = client + .put(format!("/api/v1/device/{}", device.id.unwrap())) + .json(&modified_device) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::DeviceModified(_)); + + // device details + let response = client + .get(format!("/api/v1/device/{}", device.id.unwrap())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let device_from_details: Device = response.into_json().await.unwrap(); + assert_eq!(device_from_details.name, modified_name); + assert_eq!(device_from_details.wireguard_pubkey, modified_key); + + // device config + let response = client + .get(format!("/api/v1/device/{}/config", device.id.unwrap())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let config = response.into_string().await.unwrap(); + assert_eq!( + config, + format!( + "[Interface]\n\ + PrivateKey = YOUR_PRIVATE_KEY\n\ + Address = 10.1.1.2\n\ + DNS = 1.1.1.1\n\ + \n\ + [Peer]\n\ + PublicKey = {}\n\ + AllowedIPs = 10.1.1.0/24\n\ + Endpoint = 192.168.4.14:55555\n\ + PersistentKeepalive = 300", + network_from_details.pubkey + ) + ); + + // FIXME: try to delete network, which should fail because there is a device + let response = client + .delete(format!( + "/api/v1/network/{}", + network_from_details.id.unwrap() + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::NetworkDeleted(_)); + + // delete device + let response = client + .delete(format!("/api/v1/device/{}", device.id.unwrap())) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::DeviceDeleted(_)); + + let response = client.get("/api/v1/device").json(&device).dispatch().await; + assert_eq!(response.status(), Status::Ok); + let devices: Vec = response.into_json().await.unwrap(); + assert!(devices.is_empty()); +} + +#[rocket::async_test] +async fn test_device_pubkey() { + let (pool, config) = init_test_db().await; + let (client, mut wg_rx) = make_client(pool, config).await; + + let auth = Auth::new("admin".into(), "pass123".into()); + let response = &client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // create network + let response = client + .post("/api/v1/network") + .json(&make_network()) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + let event = wg_rx.try_recv().unwrap(); + assert_matches!(event, GatewayEvent::NetworkCreated(_)); + + // network details + let response = client.get("/api/v1/network/1").dispatch().await; + assert_eq!(response.status(), Status::Ok); + let network_from_details: WireguardNetwork = response.into_json().await.unwrap(); + + // create bad device + let device = json!({ + "name": "device", + "wireguard_pubkey": network_from_details.pubkey.clone(), + }); + let response = client + .post("/api/v1/device/admin") + .json(&device) + .dispatch() + .await; + assert_eq!(response.status(), Status::BadRequest); + + // create good device + let device = json!({ + "name": "device", + "wireguard_pubkey": "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=", + }); + let response = client + .post("/api/v1/device/admin") + .json(&device) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + + // list devices + let response = client.get("/api/v1/device").json(&device).dispatch().await; + assert_eq!(response.status(), Status::Ok); + let devices: Vec = response.into_json().await.unwrap(); + assert_eq!(devices.len(), 1); + + // modify device + let mut device = devices[0].clone(); + device.wireguard_pubkey = network_from_details.pubkey; + let response = client + .put(format!("/api/v1/device/{}", device.id.unwrap())) + .json(&device) + .dispatch() + .await; + assert_eq!(response.status(), Status::BadRequest); +} + +#[rocket::async_test] +async fn test_stats() { + let (pool, config) = init_test_db().await; + let (client, _) = make_client(pool.clone(), config).await; + + let auth = Auth::new("admin".into(), "pass123".into()); + let response = &client.post("/api/v1/auth").json(&auth).dispatch().await; + assert_eq!(response.status(), Status::Ok); + + // create network + let response = client + .post("/api/v1/network") + .json(&make_network()) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + + // create devices + let device = json!({ + "name": "device-1", + "wireguard_pubkey": "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=", + }); + let response = client + .post("/api/v1/device/admin") + .json(&device) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + + let device = json!({ + "name": "device-2", + "wireguard_pubkey": "sIhx53MsX+iLk83sssybHrD7M+5m+CmpLzWL/zo8C38= + ", + }); + let response = client + .post("/api/v1/device/admin") + .json(&device) + .dispatch() + .await; + assert_eq!(response.status(), Status::Created); + + // get devices + let mut devices = Vec::::new(); + let response = client.get("/api/v1/device/1").dispatch().await; + assert_eq!(response.status(), Status::Ok); + devices.push(response.into_json().await.unwrap()); + + let response = client.get("/api/v1/device/2").dispatch().await; + assert_eq!(response.status(), Status::Ok); + devices.push(response.into_json().await.unwrap()); + + // empty stats + let now = Utc::now().naive_utc(); + let hour_ago = now - Duration::hours(1); + let response = client + .get(format!( + "/api/v1/network/stats/users?from={}", + hour_ago.format("%Y-%m-%dT%H:%M:00Z"), + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let stats: Vec = response.into_json().await.unwrap(); + assert!(stats.is_empty()); + + // insert stats + let samples = 60 * 11; // 11 hours of samples + for i in 0..samples { + for (d, device) in devices.iter().enumerate().take(2) { + let mut wps = WireguardPeerStats { + id: None, + device_id: device.id.unwrap(), + collected_at: now - Duration::minutes(i), + network: 1, + endpoint: Some("11.22.33.44".into()), + upload: (samples - i) * 10 * (d as i64 + 1), + download: (samples - i) * 20 * (d as i64 + 1), + latest_handshake: now - Duration::minutes(i * 10), + allowed_ips: Some("10.1.1.0/24".into()), + }; + wps.save(&pool).await.unwrap(); + } + } + + // minute aggregation + let response = client + .get(format!( + "/api/v1/network/stats/users?from={}", + hour_ago.format("%Y-%m-%dT%H:%M:00Z"), + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let stats: Vec = response.into_json().await.unwrap(); + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].devices.len(), 2); + assert_eq!( + stats[0].devices[0].connected_at.unwrap(), + now.trunc_subsecs(6) + ); + assert_eq!( + stats[0].devices[1].connected_at.unwrap(), + now.trunc_subsecs(6) + ); + assert_eq!(stats[0].devices[0].stats.len(), 61); + assert_eq!(stats[0].devices[1].stats.len(), 61); + let now_trunc = NaiveDate::from_ymd(now.year(), now.month(), now.day()).and_hms( + now.hour(), + now.minute(), + 0, + ); + assert_eq!( + stats[0].devices[0].stats.last().unwrap().clone(), + WireguardDeviceTransferRow { + device_id: 1, + collected_at: Some(now_trunc), + upload: 10, + download: 20, + } + ); + assert_eq!( + stats[0].devices[1].stats.last().unwrap().clone(), + WireguardDeviceTransferRow { + device_id: 2, + collected_at: Some(now_trunc), + upload: 10 * 2, + download: 20 * 2, + } + ); + assert_eq!( + stats[0].devices[0] + .stats + .iter() + .map(|s| s.upload) + .sum::(), + 10 * 61 + ); + assert_eq!( + stats[0].devices[0] + .stats + .iter() + .map(|s| s.download) + .sum::(), + 20 * 61 + ); + assert_eq!( + stats[0].devices[1] + .stats + .iter() + .map(|s| s.upload) + .sum::(), + 10 * 2 * 61 + ); + assert_eq!( + stats[0].devices[1] + .stats + .iter() + .map(|s| s.download) + .sum::(), + 20 * 2 * 61 + ); + + assert!(stats[0].devices[0].stats[0].upload > 0); + assert!(stats[0].devices[1].stats[0].upload > 0); + assert!(stats[0].devices[0].stats[0].download > 0); + assert!(stats[0].devices[1].stats[0].download > 0); + assert_eq!(stats[0].devices[0].stats.last().unwrap().upload, 10); + assert_eq!(stats[0].devices[1].stats.last().unwrap().upload, 20); + assert_eq!(stats[0].devices[0].stats.last().unwrap().download, 20); + assert_eq!(stats[0].devices[1].stats.last().unwrap().download, 40); + assert_eq!( + stats[0].devices[0] + .stats + .iter() + .filter(|s| s.upload != 10 || s.download != 20) + .count(), + 0 + ); + assert_eq!( + stats[0].devices[1] + .stats + .iter() + .filter(|s| s.upload != 20 || s.download != 40) + .count(), + 0 + ); + + // hourly aggregation + let ten_hours_ago = now - Duration::hours(10); + let ten_hours_samples = 10 * 60 + 1; + let response = client + .get(format!( + "/api/v1/network/stats/users?from={}", + ten_hours_ago.format("%Y-%m-%dT%H:%M:00Z"), + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let stats: Vec = response.into_json().await.unwrap(); + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].devices.len(), 2); + assert_eq!( + stats[0].devices[0].connected_at.unwrap(), + now.trunc_subsecs(6) + ); + assert_eq!( + stats[0].devices[1].connected_at.unwrap(), + now.trunc_subsecs(6) + ); + assert_eq!(stats[0].devices[0].stats.len(), 11); + assert_eq!(stats[0].devices[1].stats.len(), 11); + assert!(stats[0].devices[0].stats[0].upload > 0); + assert!(stats[0].devices[1].stats[0].upload > 0); + assert!(stats[0].devices[0].stats[0].download > 0); + assert!(stats[0].devices[1].stats[0].download > 0); + assert_eq!(stats[0].devices[0].stats[5].upload, 10 * 60); + assert_eq!(stats[0].devices[1].stats[5].upload, 20 * 60); + assert_eq!(stats[0].devices[0].stats[5].download, 20 * 60); + assert_eq!(stats[0].devices[1].stats[5].download, 40 * 60); + + // network stats + let response = client + .get(format!( + "/api/v1/network/stats?from={}", + ten_hours_ago.format("%Y-%m-%dT%H:%M:00Z"), + )) + .dispatch() + .await; + assert_eq!(response.status(), Status::Ok); + let stats: WireguardNetworkStats = response.into_json().await.unwrap(); + assert_eq!(stats.active_users, 1); + assert_eq!(stats.active_devices, 2); + assert_eq!(stats.upload, ten_hours_samples * (10 + 20)); + assert_eq!(stats.download, ten_hours_samples * (20 + 40)); + assert_eq!(stats.transfer_series.len(), 11); + assert!(stats.transfer_series[0].download.is_some()); + assert!(stats.transfer_series[0].upload.is_some()); + assert_eq!(stats.transfer_series[5].upload, Some((10 + 20) * 60)); + + assert_eq!(stats.transfer_series[5].download, Some((20 + 40) * 60)); + assert_eq!( + stats.upload, + stats + .transfer_series + .iter() + .map(|v| v.upload.unwrap()) + .sum::() + ); + assert_eq!( + stats.download, + stats + .transfer_series + .iter() + .map(|v| v.download.unwrap()) + .sum::() + ); +}