├── .containerignore ├── .github ├── dependabot.yml └── workflows │ ├── cd.yaml │ └── ci.yaml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── build.rs ├── compose.yaml ├── container ├── grpc │ └── containerfile └── rest │ └── containerfile ├── envoy └── envoy.yaml ├── migrations ├── 2022-01-06-164702_create_metadata │ ├── down.sql │ └── up.sql ├── 2022-01-06-164754_create_user │ ├── down.sql │ └── up.sql └── 2022-01-06-164779_create_secret │ ├── down.sql │ └── up.sql ├── proto ├── session.proto └── user.proto ├── redis └── redis.conf ├── scripts └── build_db_setup_script.py └── src ├── base64.rs ├── bin ├── grpc.rs └── rest.rs ├── config.rs ├── crypto.rs ├── email.rs ├── grpc.rs ├── http.rs ├── lib.rs ├── metadata ├── application.rs ├── domain.rs ├── mod.rs └── repository.rs ├── rabbitmq.rs ├── regex.rs ├── result.rs ├── secret ├── application.rs ├── domain.rs ├── mod.rs └── repository.rs ├── session ├── application.rs ├── grpc.rs ├── mod.rs └── rest.rs ├── smtp.rs ├── time.rs ├── token ├── application.rs ├── domain.rs ├── mod.rs └── repository.rs └── user ├── application.rs ├── domain.rs ├── event_bus.rs ├── grpc.rs ├── mod.rs └── repository.rs /.containerignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | *.env 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Generated by Cargo 16 | # will have compiled files and executables 17 | debug/ 18 | target/ 19 | 20 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 21 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 22 | Cargo.lock 23 | 24 | # These are backup files generated by rustfmt 25 | **/*.rs.bk 26 | 27 | # ignore all postgresdb init scripts 28 | .postgres -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "cargo" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous delivery 2 | 3 | on: 4 | release: 5 | types: published 6 | 7 | workflow_dispatch: 8 | 9 | env: 10 | IMAGE_REGISTRY: docker.io 11 | IMAGE_NAME: alvidir/rauth 12 | 13 | jobs: 14 | push_grpc_server_to_registry: 15 | name: Push gRPC server image 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v3 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Set configuration 25 | run: | 26 | target="[machine]" 27 | want="#[machine]" 28 | cat /usr/share/containers/containers.conf | sed "s/$target/$want/" > /usr/share/containers/containers.conf 29 | 30 | - name: Build image 31 | id: build-image 32 | uses: redhat-actions/buildah-build@v2.12 33 | with: 34 | image: ${{ env.IMAGE_NAME }} 35 | tags: ${{ github.event.release.tag_name }}-grpc 36 | containerfiles: | 37 | ./container/grpc/containerfile 38 | 39 | - name: Log in to registry 40 | uses: redhat-actions/podman-login@v1 41 | with: 42 | username: ${{ secrets.DOCKER_USERNAME }} 43 | password: ${{ secrets.DOCKER_PASSWORD }} 44 | registry: ${{ env.IMAGE_REGISTRY }} 45 | 46 | - name: Push image 47 | uses: redhat-actions/push-to-registry@v2 48 | with: 49 | image: ${{ steps.build-image.outputs.image }} 50 | tags: ${{ steps.build-image.outputs.tags }} 51 | registry: ${{ env.IMAGE_REGISTRY }} 52 | 53 | push_rest_server_to_registry: 54 | name: Push REST server image 55 | runs-on: ubuntu-latest 56 | 57 | steps: 58 | - name: Checkout code 59 | uses: actions/checkout@v3 60 | with: 61 | fetch-depth: 0 62 | 63 | - name: Set configuration 64 | run: | 65 | target="[machine]" 66 | want="#[machine]" 67 | cat /usr/share/containers/containers.conf | sed "s/$target/$want/" > /usr/share/containers/containers.conf 68 | 69 | - name: Build image 70 | id: build-image 71 | uses: redhat-actions/buildah-build@v2.12 72 | with: 73 | image: ${{ env.IMAGE_NAME }} 74 | tags: ${{ github.event.release.tag_name }}-rest 75 | containerfiles: | 76 | ./container/rest/containerfile 77 | 78 | - name: Log in to registry 79 | uses: redhat-actions/podman-login@v1 80 | with: 81 | username: ${{ secrets.DOCKER_USERNAME }} 82 | password: ${{ secrets.DOCKER_PASSWORD }} 83 | registry: ${{ env.IMAGE_REGISTRY }} 84 | 85 | - name: Push image 86 | uses: redhat-actions/push-to-registry@v2 87 | with: 88 | image: ${{ steps.build-image.outputs.image }} 89 | tags: ${{ steps.build-image.outputs.tags }} 90 | registry: ${{ env.IMAGE_REGISTRY }} 91 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous integration 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | changelog: 11 | name: Changelog 12 | runs-on: ubuntu-latest 13 | 14 | outputs: 15 | changelog: ${{ steps.changed-files.outputs.all_changed_files }} 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Get changed files 24 | id: changed-files 25 | uses: tj-actions/changed-files@v37.0.5 26 | 27 | run_unitary_tests: 28 | name: Run clippy and unitary tests 29 | runs-on: ubuntu-latest 30 | 31 | needs: changelog 32 | if: | 33 | contains(needs.changelog.outputs.changelog, 'src') || 34 | contains(needs.changelog.outputs.changelog, 'Cargo.toml') || 35 | contains(needs.changelog.outputs.changelog, 'proto') 36 | 37 | steps: 38 | - name: Checkout code 39 | uses: actions/checkout@v3 40 | with: 41 | fetch-depth: 0 42 | 43 | - name: Install stable 44 | uses: actions-rs/toolchain@v1 45 | with: 46 | toolchain: stable 47 | components: clippy 48 | 49 | - name: Install protoc 50 | uses: arduino/setup-protoc@v1 51 | with: 52 | version: "3.x" 53 | 54 | - name: Cargo clippy 55 | uses: actions-rs/clippy@master 56 | 57 | - name: Cargo test 58 | uses: actions-rs/cargo@v1 59 | with: 60 | command: test 61 | args: --verbose 62 | 63 | coverage: 64 | name: Compute code coverage 65 | runs-on: ubuntu-latest 66 | 67 | steps: 68 | - uses: actions/checkout@v3 69 | with: 70 | submodules: true 71 | 72 | - name: Install stable 73 | uses: actions-rs/toolchain@v1 74 | with: 75 | profile: minimal 76 | toolchain: stable 77 | components: llvm-tools-preview 78 | 79 | - name: Install protoc 80 | uses: arduino/setup-protoc@v1 81 | with: 82 | version: "3.x" 83 | 84 | - name: Cargo install cargo-llvm-cov 85 | uses: taiki-e/install-action@cargo-llvm-cov 86 | 87 | - name: Cargo generate-lockfile 88 | if: hashFiles('Cargo.lock') == '' 89 | uses: actions-rs/cargo@v1 90 | with: 91 | command: generate-lockfile 92 | 93 | - name: Cargo llvm-cov 94 | run: cargo llvm-cov --locked --lcov --output-path lcov.info --no-default-features -- --nocapture 95 | 96 | - name: Upload coverage reports to Codecov 97 | uses: codecov/codecov-action@v3 98 | with: 99 | fail_ci_if_error: true 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | *.env 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Generated by Cargo 16 | # will have compiled files and executables 17 | debug/ 18 | target/ 19 | 20 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 21 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 22 | Cargo.lock 23 | 24 | # These are backup files generated by rustfmt 25 | **/*.rs.bk 26 | 27 | # ignore all postgresdb init scripts 28 | .postgres 29 | 30 | # html templates for rendering 31 | templates/ 32 | 33 | # keys and secrets 34 | secrets/ -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "rust-lang.rust-analyzer", 4 | "serayuzgur.crates", 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[rust]": { 3 | "editor.defaultFormatter": "rust-lang.rust-analyzer" 4 | }, 5 | "rust-analyzer.checkOnSave.command": "clippy", 6 | "editor.formatOnSave": true, 7 | "editor.detectIndentation": false, 8 | "editor.tabSize": 4, 9 | "rust-analyzer.linkedProjects": [ 10 | "./Cargo.toml", 11 | "./Cargo.toml", 12 | "./Cargo.toml" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rauth" 3 | version = "1.0.0" 4 | authors = ["Hèctor Morales "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | actix-web = { version = "4.3.1", optional = true } # rest 9 | async_once = "0.2.6" 10 | async-trait = "0.1.68" 11 | base64 = "0.21.2" 12 | chrono = "0.4.26" 13 | deadpool-lapin = { version = "0.10.0", optional = true } 14 | dotenv = "0.15.0" 15 | jsonwebtoken = "8.3.0" 16 | lapin = { version = "2.2.1", optional = true } 17 | lazy_static = "1.4.0" 18 | lettre = "0.10.4" 19 | libreauth = "0.16.0" 20 | once_cell = "1.18.0" 21 | openssl = "0.10.54" 22 | prost = { version = "0.11.9", optional = true } # protobuf 23 | protoc = { version = "2.28.0", optional = true } 24 | rand = "0.8.5" 25 | redis = { version = "0.23.0", features = ["tokio-comp"], optional = true } 26 | regex = "1.8.4" 27 | reool = { version = "0.30.0", optional = true } 28 | serde = "1.0.164" # data parser 29 | serde_json = "1.0.96" 30 | sha256 = "1.1.4" 31 | sqlx = { version = "0.6.3", features = [ 32 | "runtime-tokio-rustls", 33 | "postgres", 34 | "chrono", 35 | ], optional = true } 36 | strum = "0.25.0" 37 | strum_macros = "0.25.0" 38 | tera = "1.19.0" # template engine 39 | tokio = { version = "1.28.2", features = ["macros", "rt", "rt-multi-thread"] } 40 | tonic = { version = "0.9.2", optional = true } # gRPC 41 | tracing = "0.1" 42 | tracing-subscriber = "0.3" 43 | 44 | [build-dependencies] 45 | tonic-build = "0.9.2" 46 | 47 | [lib] 48 | name = "rauth" 49 | path = "src/lib.rs" 50 | 51 | [features] 52 | default = ["config", "grpc", "rest", "postgres", "rabbitmq", "redis-cache"] 53 | config = [] 54 | grpc = ["prost", "protoc", "tonic"] 55 | postgres = ["sqlx"] 56 | rabbitmq = ["deadpool-lapin", "lapin"] 57 | redis-cache = ["redis", "reool"] 58 | rest = ["actix-web"] 59 | 60 | [[bin]] 61 | name = "grpc" 62 | path = "src/bin/grpc.rs" 63 | required-features = ["grpc"] 64 | 65 | [[bin]] 66 | name = "rest" 67 | path = "src/bin/rest.rs" 68 | required-features = ["rest"] 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alvidir 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY_NAME=rauth 2 | VERSION?=latest 3 | PKG_MANAGER?=dnf 4 | 5 | all: binaries 6 | 7 | binaries: install-deps 8 | ifdef target 9 | cargo build --bin $(target) --no-default-features --features $(target) --release 10 | else 11 | -cargo build --bin grpc --no-default-features --features grpc --release 12 | -cargo build --bin rest --no-default-features --features rest --release 13 | endif 14 | 15 | images: 16 | ifdef target 17 | podman build -t alvidir/$(BINARY_NAME):$(VERSION)-$(target) -f ./container/$(target)/containerfile . 18 | else 19 | -podman build -t alvidir/$(BINARY_NAME):$(VERSION)-grpc -f ./container/grpc/containerfile . 20 | -podman build -t alvidir/$(BINARY_NAME):$(VERSION)-rest -f ./container/rest/containerfile . 21 | endif 22 | 23 | push-images: 24 | ifdef target 25 | @podman push alvidir/$(BINARY_NAME):$(VERSION)-$(target) 26 | else 27 | @-podman push alvidir/$(BINARY_NAME):$(VERSION)-grpc 28 | @-podman push alvidir/$(BINARY_NAME):$(VERSION)-rest 29 | endif 30 | 31 | install-deps: 32 | -$(PKG_MANAGER) install -y protobuf-compiler 33 | -$(PKG_MANAGER) install -y postgresql-devel 34 | -$(PKG_MANAGER) install -y openssl-devel 35 | -$(PKG_MANAGER) install -y pkg-config 36 | 37 | clean: 38 | @-cargo clean 39 | @-rm -rf bin/ o 40 | @-rm -rf secrets/ 41 | 42 | clean-images: 43 | @-podman image rm alvidir/$(BINARY_NAME):$(VERSION)-grpc 44 | @-podman image rm alvidir/$(BINARY_NAME):$(VERSION)-rest 45 | 46 | test: 47 | @RUST_BACKTRACE=full cargo test --no-default-features -- --nocapture 48 | 49 | secrets: 50 | @mkdir -p secrets/ 51 | @openssl ecparam -name prime256v1 -genkey -noout -out secrets/ec_key.pem 52 | @openssl ec -in secrets/ec_key.pem -pubout -out secrets/ec_pubkey.pem 53 | @openssl pkcs8 -topk8 -nocrypt -in secrets/ec_key.pem -out secrets/pkcs8_key.pem 54 | @cat secrets/ec_key.pem | base64 | tr -d '\n' > secrets/ec_key.base64 55 | @cat secrets/ec_pubkey.pem | base64 | tr -d '\n' > secrets/ec_pubkey.base64 56 | @cat secrets/pkcs8_key.pem | base64 | tr -d '\n' > secrets/pkcs8_key.base64 57 | 58 | deploy: 59 | @python3 scripts/build_db_setup_script.py 60 | @podman-compose -f compose.yaml up -d 61 | 62 | undeploy: 63 | @podman-compose -f compose.yaml down -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rauth 2 | 3 | [![Continuos Integration](https://github.com/alvidir/rauth/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/alvidir/rauth/actions/workflows/ci.yaml) 4 | [![Code Coverage](https://codecov.io/github/alvidir/rauth/coverage.svg?branch=main&token=)](https://codecov.io/gh/alvidir/rauth) 5 | [![Dependency status](https://deps.rs/repo/github/alvidir/rauth/status.svg)](https://deps.rs/repo/github/alvidir/rauth) 6 | [![rauth](https://img.shields.io/github/v/release/alvidir/rauth.svg)](https://github.com/alvidir/rauth) 7 | 8 | A simple SSO implementation in Rust 9 | 10 | ## Table of contents 11 | 12 | 1. [About](#about) 13 | 1. [Endpoints](#example2) 14 | 1. [Signup](#signup) 15 | 1. [Reset](#reset) 16 | 1. [Delete](#delete) 17 | 1. [Totp](#totp) 18 | 1. [Login](#login) 19 | 1. [Logout](#logout) 20 | 1. [Setup environment](#setup-environment) 21 | 1. [Server configuration](#server-configuration) 22 | 1. [Deployment](#deployment) 23 | 1. [Debugging](#debugging) 24 | 25 | ## About 26 | 27 | The rauth project provides a **SSO** (Single Sign On) implementation that can be consumed as any of both, a [Rust](https://www.rust-lang.org/) library or a [gRPC](https://grpc.io/) service. Currently, the project includes all regular session-related actions as signup, login, logout, and so on. Plus **TOTP**(Time-based One Time Password) and email verification support. 28 | 29 | ## Endpoints 30 | 31 | ### **Signup** 32 | 33 | Allows a new user to get registered into the system if, and only if, `email` and `password` are both valid. 34 | 35 | #### Request 36 | 37 | The **signup** transaction requires of **two steps** to get completed: the _signup request_, and the _email verification_. Both of them use the same endpoint to get performed, nonetheless, the _signup request_ is the only one that must all fields. The _email verification_ instead, shall provide the **verification token** in the corresponding header. 38 | 39 | ```yaml 40 | # Example of a gRPC message for the signup endpoint 41 | 42 | { 43 | "email": "dummy@test.com" # an string containing the user's email, 44 | "pwd": "1234567890ABCDEF" # an string containing the user's password encoded in base64 45 | } 46 | ``` 47 | 48 | #### Response 49 | 50 | - If, and only if, the first step of the signup transaction completed successfully, Rauth will respond with the error `E003` (require email verification). 51 | - If, and only if, the email verification completed successfully, is sent an Empty response with the session token in the corresponding header. 52 | - Otherwise, is provided one of the errors down below. 53 | 54 | #### Error codes 55 | 56 | | **Code** | Name | Description | 57 | | :------- | :----------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- | 58 | | **E001** | ERR_UNKNOWN | Unprevisible errors | 59 | | **E002** | ERR_NOT_FOUND | Token header not found | 60 | | **E005** | ERR_INVALID_TOKEN | Token is invalid because of any of the following reasons: bad format, `exp` time exceeded, bad signature, `nbf` not satisfied, wrong `knd` or not catched. | 61 | | **E006** | ERR_INVALID_FORMAT | Invalid format for `email` or `password` | 62 | | **E007** | ERR_INVALID_HEADER | Token header must be encoded in base64 | 63 | 64 | ### **Reset** 65 | 66 | Allows an existing user to reset its password. 67 | 68 | #### Request 69 | 70 | The **reset** transaction requires of **two steps** to get completed: the _email verification_, and the _password reset_. Both of them use the same endpoint to get performed, nonetheless, they do differ in which fields are mandatory. 71 | 72 | ```yaml 73 | # Example of a gRPC message for the first step of the reset endpoint 74 | 75 | { 76 | "email": "dummy@test.com" # an string containing the user's email, 77 | "pwd": "" # not required 78 | "totp": "" # not required 79 | } 80 | 81 | # Example of a gRPC message for the second step of the reset endpoint 82 | { 83 | "email": "" # not required 84 | "pwd": "1234567890ABCDEF" # an string containing the user's password encoded in base64 85 | "totp": "123456" # the TOTP of the user, if enabled 86 | } 87 | ``` 88 | 89 | > The second step must provide in the corresponding header the token that the verification email gave to ensure the legitimacy of the action. 90 | 91 | #### Response 92 | 93 | - If, and only if, the first step of the reset transaction completed successfully, Rauth will respond with the error `E003` (require email verification). 94 | - If, and only if, the password reset completed successfully, is sent an Empty response with no errors. 95 | - Otherwise, is provided one of the errors down below. 96 | 97 | #### Error codes 98 | 99 | | **Code** | Name | Description | 100 | | :------- | :-------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- | 101 | | **E001** | ERR_UNKNOWN | Unprevisible errors | 102 | | **E002** | ERR_NOT_FOUND | Token header not found | 103 | | **E004** | ERR_UNAUTHORIZED | Totp required | 104 | | **E005** | ERR_INVALID_TOKEN | Token is invalid because of any of the following reasons: bad format, `exp` time exceeded, bad signature, `nbf` not satisfied, wrong `knd` or not catched. | 105 | | **E006** | ERR_INVALID_FORMAT | Password must be encoded in base64 | 106 | | **E007** | ERR_INVALID_HEADER | Token header must be encoded in base64 | 107 | | **E008** | ERR_WRONG_CREDENTIALS | The new password cannot match the old one or invalid `user id`. | 108 | 109 | ### **Delete** 110 | 111 | Allows an existing user to delete its account. 112 | 113 | #### Request 114 | 115 | The **delete** transaction requires the user to be logged in, so its session token must be provided in the corresponding header of the request. 116 | 117 | ```yaml 118 | # Example of a gRPC message for the delete endpoint 119 | 120 | { 121 | "pwd": "1234567890ABCDEF" # an string containing the user's password encoded in base64 122 | "totp": "123456" # the TOTP of the user, if enabled 123 | } 124 | ``` 125 | 126 | #### Response 127 | 128 | - If, and only if, the deletion completed successfully, is sent an Empty response with no errors. 129 | - Otherwise, is provided one of the errors down below. 130 | 131 | #### Error codes 132 | 133 | | **Code** | Name | Description | 134 | | :------- | :-------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- | 135 | | **E001** | ERR_UNKNOWN | Unprevisible errors | 136 | | **E002** | ERR_NOT_FOUND | Token header not found | 137 | | **E004** | ERR_UNAUTHORIZED | Totp required | 138 | | **E005** | ERR_INVALID_TOKEN | Token is invalid because of any of the following reasons: bad format, `exp` time exceeded, bad signature, `nbf` not satisfied, wrong `knd` or not catched. | 139 | | **E007** | ERR_INVALID_HEADER | Token header must be encoded in base64 | 140 | | **E008** | ERR_WRONG_CREDENTIALS | Password does not match or invalid `user id`. | 141 | 142 | ### **Totp** 143 | 144 | Allows an existing user to enable or disable the time-based one time password 145 | 146 | #### Request 147 | 148 | The **totp** transaction requires the user to be logged in, so its session token must be provided in the corresponding header of the request. Besides, the enabling option requires of **two steps** to get completed: the action itself, and the totp verification. In any case, the same endpoint is consumed. 149 | 150 | ```yaml 151 | # Example of a gRPC message for the totp endpoint 152 | 153 | { 154 | "action": x, # where x may be 0 or 1 for enabling or disabling totp respectively 155 | "pwd": "1234567890ABCDEF" # an string containing the user's password encoded in base64 156 | "totp": "" # not required if, and only if, is the first step of enabling totp 157 | } 158 | 159 | # Example of a gRPC message for the second step of enabling the totp 160 | 161 | { 162 | "action": 0, # 0: enable totp action 163 | "pwd": "1234567890ABCDEF" # an string containing the user's password encoded in base64 164 | "totp": "123456" # the correct totp for the given secret 165 | } 166 | ``` 167 | 168 | #### Response 169 | 170 | - If, and only if, the first step of enabling the TOTP completed successfully, is provided the TOTP's secret in the corresponding header. 171 | - If, and only if, the second step of enabling the TOTP completed successfully, is sent an Empty response with no errors. 172 | - If, and only if, disabling TOTP completed successfully, is sent an Empty response with no errors. 173 | - Otherwise, is provided one of the errors down below. 174 | 175 | #### Error codes 176 | 177 | | **Code** | Name | Description | 178 | | :------- | :-------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- | 179 | | **E001** | ERR_UNKNOWN | Unprevisible errors | 180 | | **E002** | ERR_NOT_FOUND | Token header not found | 181 | | **E003** | ERR_NOT_AVAILABLE | The action cannot be performed | 182 | | **E004** | ERR_UNAUTHORIZED | Invalid `totp` value | 183 | | **E005** | ERR_INVALID_TOKEN | Token is invalid because of any of the following reasons: bad format, `exp` time exceeded, bad signature, `nbf` not satisfied, wrong `knd` or not catched. | 184 | | **E007** | ERR_INVALID_HEADER | Token header must be encoded in base64 | 185 | | **E008** | ERR_WRONG_CREDENTIALS | Password does not match or invalid `user id`. | 186 | 187 | ### **Login** 188 | 189 | Allows an existing user to log in. 190 | 191 | #### Request 192 | 193 | ```yaml 194 | # Example of a gRPC message for the login endpoint 195 | 196 | { 197 | "ident": "dummy" # username or password 198 | "pwd": "1234567890ABCDEF" # an string containing the user's password encoded in base64 199 | "totp": "123456" # the TOTP of the user, if enabled 200 | } 201 | ``` 202 | 203 | #### Response 204 | 205 | - If, and only if, the login completed successfully, is sent an Empty response with the session token in the corresponding header. 206 | - Otherwise, is provided one of the errors down below. 207 | 208 | #### Error codes 209 | 210 | | **Code** | Name | Description | 211 | | :------- | :-------------------- | :------------------------------- | 212 | | **E001** | ERR_UNKNOWN | Unprevisible errors | 213 | | **E004** | ERR_UNAUTHORIZED | Totp required | 214 | | **E008** | ERR_WRONG_CREDENTIALS | Invalid `username` or `password` | 215 | 216 | ### **Logout** 217 | 218 | Allows an existing user to log out. 219 | 220 | #### Request 221 | 222 | The **logout** transaction requires the user to be logged in, so its session token must be provided in the corresponding header of the `Empty` request. 223 | 224 | #### Response 225 | 226 | - If, and only if, the logout completed successfully, is sent an Empty response with no errors. 227 | - Otherwise, is provided one of the errors down below. 228 | 229 | #### Error codes 230 | 231 | | **Code** | Name | Description | 232 | | :------- | :----------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- | 233 | | **E001** | ERR_UNKNOWN | Unprevisible errors | 234 | | **E002** | ERR_NOT_FOUND | Token header not found | 235 | | **E005** | ERR_INVALID_TOKEN | Token is invalid because of any of the following reasons: bad format, `exp` time exceeded, bad signature, `nbf` not satisfied, wrong `knd` or not catched. | 236 | | **E007** | ERR_INVALID_HEADER | Token header must be encoded in base64 | 237 | 238 | ## Setup environment 239 | 240 | To get the environment ready for the application to run, several steps have to be completed. Luckily all commands are in the [Makefile](./Makefile) of this project, so don't panic ;) 241 | 242 | Running the following command in your terminal will create an sql setup script at `migrations/.postgres` as well as a `.ssh` directory where to find the JWT keypair required by the server: 243 | 244 | ```bash 245 | $ make setup 246 | ``` 247 | 248 | > This command requires python3 and openssl to be installed 249 | 250 | Since is expected to deploy the application using [podman](https://podman.io/), build the rauth image: 251 | 252 | ```bash 253 | $ make build 254 | ``` 255 | 256 | Last but not least, the service will expect a directory (`templates` by default) with the following email templates: 257 | 258 | | Filename | Description | 259 | | :------------------------ | :------------------------------------------------------------------------------------ | 260 | | `verification_email.html` | The html template to render and send when an email has to be verified. | 261 | | `reset_email.html` | The html template to render and send when a user requests for resetting its password. | 262 | 263 | > Both templates may consume the same variables: `name` and `token`, provided by the server while rendering. 264 | 265 | ## Server configuration 266 | 267 | The server expects a set of environment variables to work properly. Although some of them has a default value, it is recommended to set all of them to have absolute awareness about how the service will behave. 268 | 269 | | Environment variable | Default value | Description | 270 | | :---------------------- | :-------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------- | 271 | | SERVICE_PORT | 8000 | Port where to expose the service service | 272 | | SERVICE_ADDR | 127.0.0.1 | Address where to expose the service service | 273 | | POSTGRES_DSN | | `Postgres` data source name | 274 | | POSTGRES_POOL | 10 | `Postgres` connection pool size | 275 | | REDIS_URL | | `Redis` URL | 276 | | REDIS_POOL | 10 | `Redis` connection pool size | 277 | | TOKEN_TIMEOUT | 7200 | The timeout any token should have | 278 | | JWT_SECRET | | The JWT secret to sign with all generated tokens (tip: it could be the content of the .ssh/pkcs8_key.base64 file generated on the setup step) | 279 | | JWT_PUBLIC | | The JWT public key to verify with all comming tokens (tip: it could be the content of the .ssh/pkcs8_pubkey.base64 file generated on the setup step) | 280 | | JWT_HEADER | authorization | Header where to find/store all JWT | 281 | | TOTP_HEADER | x-totp-secret | Header where to set the TOTP secret | 282 | | SMTP_ISSUER | rauth | Name to identify where the emails are sent from | 283 | | SMTP_ORIGIN | | Email to set as the `from` for all sent emails | 284 | | SMTP_TRANSPORT | | Smtp transporter URL (ex.: smtp.gmail.com) | 285 | | SMTP_TEMPLATES | /etc/rauth/smtp/templates/\*.html | Path where to find all email's templates | 286 | | SMTP_USERNAME | | If required, a username to enable the application to send emails | 287 | | SMTP_PASSWORD | | If required, an application password to enable the application to send emails | 288 | | PWD_SUFIX | ::PWD::RAUTH | A suffix to append to all passwords before hashing and storing them | 289 | | RABBITMQ_USERS_EXCHANGE | | The RabbitMQ exchange to emit user related events | 290 | | RABBITMQ_URL | | `RabbitMQ` URL | 291 | | RABBITMQ_POOL | 10 | `RabbitMQ` connection pool size | 292 | | EVENT_ISSUER | | Issuer name for all emited events | 293 | | TOTP_SECRET_LEN | | Length of the random generated secret to be used for the TOTP | 294 | | TOTP_SECRET_NAME | | Name by which every TOTP secret will be stored in the database | 295 | | TOKEN_ISSUER | | Issuer value for the `iss` field of any generated token | 296 | 297 | > All these environment variables can be set in a .env file, since Rauth uses dotenv to set up the environment 298 | 299 | ## Deployment 300 | 301 | Since the application needs some external services to be launched, the easiest way to deploy them all is by using [podman-compose](https://github.com/containers/podman-compose) as following: 302 | 303 | ```bash 304 | $ make deploy 305 | ``` 306 | 307 | This command will deploy a pod with all those services described in the [compose file](./compose.yaml) of this project. Once completed, the application endpoints will be reachable in two different ways: 308 | 309 | - via `grpc` messaging on port `8000` 310 | - via `grpc-web` requests on port `8080` 311 | 312 | ## Debugging 313 | 314 | By default, the deployment command has the `-d` flag enabled, so no logs are displayed. If you really want to see them, you have two options: removing the `-d` flag from the `deploy` command of the [Makefile](./Makefile), which will display all logs of all services, or running the following command to display only those coming from the `rauth-server`: 315 | 316 | ```bash 317 | $ make follow 318 | ``` 319 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | fn main() -> Result<(), Box> { 4 | // compiling protos using path on build time 5 | tonic_build::compile_protos("proto/user.proto")?; 6 | tonic_build::compile_protos("proto/session.proto")?; 7 | 8 | Ok(()) 9 | } 10 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | container_name: rauth-postgres 4 | image: docker.io/postgres:alpine3.17 5 | restart: on-failure 6 | volumes: 7 | - dbdata:/data/postgres 8 | - ./migrations/.postgres:/docker-entrypoint-initdb.d 9 | security_opt: 10 | label: disable 11 | env_file: 12 | - .env 13 | 14 | redis: 15 | container_name: rauth-redis 16 | image: docker.io/redis:alpine3.17 17 | restart: always 18 | volumes: 19 | - ./redis:/usr/local/etc/redis:ro 20 | security_opt: 21 | label: disable 22 | command: "redis-server /usr/local/etc/redis/redis.conf" 23 | 24 | rabbitmq: 25 | container_name: rauth-rabbitmq 26 | image: docker.io/rabbitmq:3.10.2-alpine 27 | hostname: rauth-rabbitmq 28 | restart: always 29 | security_opt: 30 | label: disable 31 | 32 | grpc: 33 | container_name: rauth-grpc 34 | image: localhost/alvidir/rauth:latest-grpc 35 | restart: always 36 | ports: 37 | - 8000:8000 38 | volumes: 39 | - ./templates:/etc/rauth/smtp/templates:ro 40 | security_opt: 41 | label: disable 42 | depends_on: 43 | - postgres 44 | - redis 45 | - rabbitmq 46 | env_file: 47 | - .env 48 | environment: 49 | - SERVICE_PORT=8000 50 | 51 | rest: 52 | container_name: rauth-rest 53 | image: localhost/alvidir/rauth:latest-rest 54 | restart: always 55 | ports: 56 | - 8001:8001 57 | security_opt: 58 | label: disable 59 | depends_on: 60 | - redis 61 | env_file: 62 | - .env 63 | environment: 64 | - SERVICE_PORT=8001 65 | 66 | envoy: 67 | container_name: rauth-envoy 68 | image: docker.io/envoyproxy/envoy-alpine:v1.21-latest 69 | restart: always 70 | ports: 71 | - 8080:8080 72 | - 9901:9901 73 | volumes: 74 | - ./envoy:/etc/envoy:ro 75 | security_opt: 76 | label: disable 77 | depends_on: 78 | - grpc 79 | command: /usr/local/bin/envoy --log-level debug -c /etc/envoy/envoy.yaml 80 | 81 | mailhog: 82 | container_name: mailhog 83 | image: docker.io/mailhog/mailhog:v1.0.1 84 | restart: always 85 | ports: 86 | - 8025:8025 87 | security_opt: 88 | label: disable 89 | 90 | volumes: 91 | dbdata: 92 | -------------------------------------------------------------------------------- /container/grpc/containerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/rust:1.70.0 as builder 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y software-properties-common && \ 5 | add-apt-repository ppa:george-edison55/cmake-3.x && \ 6 | apt-get install -y pkg-config libssl-dev libpq-dev cmake && \ 7 | apt-get install -y protobuf-compiler && \ 8 | apt-get upgrade -y 9 | 10 | WORKDIR /app 11 | RUN rustup component add rustfmt --toolchain 1.70.0-x86_64-unknown-linux-gnu 12 | 13 | ADD . . 14 | RUN PKG_MANAGER=apt-get make all target=grpc 15 | 16 | ######## Start a new stage from scratch ####### 17 | FROM docker.io/debian:stable-slim 18 | ARG APP=/usr/src/app 19 | 20 | RUN apt-get update \ 21 | && apt-get install -y tzdata libssl-dev libpq-dev \ 22 | && rm -rf /var/lib/apt/lists/* 23 | 24 | ENV TZ=Etc/UTC \ 25 | APP_USER=appuser 26 | 27 | RUN groupadd $APP_USER \ 28 | && useradd -g $APP_USER $APP_USER \ 29 | && mkdir -p ${APP} 30 | 31 | COPY --from=builder /app/target/release/grpc ${APP}/rauth-grpc 32 | 33 | RUN chown -R $APP_USER:$APP_USER ${APP} 34 | 35 | USER $APP_USER 36 | WORKDIR ${APP} 37 | 38 | CMD ["./rauth-grpc"] -------------------------------------------------------------------------------- /container/rest/containerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/rust:1.70.0 as builder 2 | 3 | RUN USER=root cargo new --bin rauth 4 | RUN apt-get update && \ 5 | apt-get install -y software-properties-common && \ 6 | add-apt-repository ppa:george-edison55/cmake-3.x && \ 7 | apt-get install -y pkg-config libssl-dev libpq-dev cmake && \ 8 | apt-get upgrade -y 9 | 10 | WORKDIR /app 11 | RUN rustup component add rustfmt --toolchain 1.70.0-x86_64-unknown-linux-gnu 12 | 13 | ADD . . 14 | RUN PKG_MANAGER=apt-get make all target=rest 15 | 16 | ######## Start a new stage from scratch ####### 17 | FROM docker.io/debian:stable-slim 18 | ARG APP=/usr/src/app 19 | 20 | RUN apt-get update \ 21 | && apt-get install -y tzdata libssl-dev libpq-dev \ 22 | && rm -rf /var/lib/apt/lists/* 23 | 24 | ENV TZ=Etc/UTC \ 25 | APP_USER=appuser 26 | 27 | RUN groupadd $APP_USER \ 28 | && useradd -g $APP_USER $APP_USER \ 29 | && mkdir -p ${APP} 30 | 31 | COPY --from=builder /app/target/release/rest ${APP}/rauth-rest 32 | 33 | RUN chown -R $APP_USER:$APP_USER ${APP} 34 | 35 | USER $APP_USER 36 | WORKDIR ${APP} 37 | 38 | CMD ["./rauth-rest"] -------------------------------------------------------------------------------- /envoy/envoy.yaml: -------------------------------------------------------------------------------- 1 | admin: 2 | address: 3 | socket_address: { address: 0.0.0.0, port_value: 9901 } 4 | 5 | static_resources: 6 | listeners: 7 | - name: listener_0 8 | address: 9 | socket_address: { address: 0.0.0.0, port_value: 8080 } 10 | filter_chains: 11 | - filters: 12 | - name: envoy.filters.network.http_connection_manager 13 | typed_config: 14 | "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager 15 | codec_type: auto 16 | stat_prefix: ingress_http 17 | route_config: 18 | name: local_route 19 | virtual_hosts: 20 | - name: local_service 21 | domains: ["*"] 22 | routes: 23 | - match: 24 | prefix: "/" 25 | grpc: 26 | route: 27 | cluster: rauth_service 28 | cors: 29 | allow_origin_string_match: 30 | - prefix: "*" 31 | allow_methods: GET, PUT, DELETE, POST, OPTIONS 32 | allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,grpc-status-details-bin,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout,authorization 33 | expose_headers: grpc-status-details-bin,grpc-status,grpc-message,authorization 34 | max_age: "1728000" 35 | http_filters: 36 | - name: envoy.filters.http.grpc_web 37 | - name: envoy.filters.http.cors 38 | - name: envoy.filters.http.router 39 | clusters: 40 | - name: rauth_service 41 | type: logical_dns 42 | connect_timeout: 0.25s 43 | lb_policy: round_robin 44 | http2_protocol_options: {} 45 | load_assignment: 46 | cluster_name: rauth_service 47 | endpoints: 48 | - lb_endpoints: 49 | - endpoint: 50 | address: 51 | socket_address: { address: rauth-grpc, port_value: 8000 } 52 | 53 | layered_runtime: 54 | layers: 55 | - name: static_layer_0 56 | static_layer: 57 | envoy: 58 | resource_limits: 59 | listener: 60 | listener_0: 61 | connection_limit: 10000 62 | overload: 63 | global_downstream_max_connections: 50000 64 | -------------------------------------------------------------------------------- /migrations/2022-01-06-164702_create_metadata/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE Metadata; -------------------------------------------------------------------------------- /migrations/2022-01-06-164702_create_metadata/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE Metadata ( 3 | id SERIAL PRIMARY KEY, 4 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 5 | updated_at TIMESTAMP NOT NULL DEFAULT NOW(), 6 | deleted_at TIMESTAMP 7 | ); -------------------------------------------------------------------------------- /migrations/2022-01-06-164754_create_user/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE Users; -------------------------------------------------------------------------------- /migrations/2022-01-06-164754_create_user/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE Users ( 3 | id SERIAL PRIMARY KEY, 4 | name VARCHAR(64) NOT NULL UNIQUE, 5 | email VARCHAR(64) NOT NULL UNIQUE, 6 | actual_email VARCHAR(64) NOT NULL UNIQUE, 7 | password VARCHAR(255) NOT NULL, 8 | meta_id INTEGER NOT NULL UNIQUE, 9 | 10 | FOREIGN KEY (meta_id) 11 | REFERENCES Metadata(id) 12 | ); -------------------------------------------------------------------------------- /migrations/2022-01-06-164779_create_secret/down.sql: -------------------------------------------------------------------------------- 1 | -- This file should undo anything in `up.sql` 2 | DROP TABLE Secrets; -------------------------------------------------------------------------------- /migrations/2022-01-06-164779_create_secret/up.sql: -------------------------------------------------------------------------------- 1 | -- Your SQL goes here 2 | CREATE TABLE Secrets ( 3 | id SERIAL PRIMARY KEY, 4 | name VARCHAR(64) NOT NULL, 5 | data TEXT NOT NULL, 6 | user_id INTEGER NOT NULL, 7 | meta_id INTEGER NOT NULL UNIQUE, 8 | 9 | UNIQUE (name, user_id), 10 | 11 | FOREIGN KEY (user_id) 12 | REFERENCES Users(id), 13 | FOREIGN KEY (meta_id) 14 | REFERENCES Metadata(id) 15 | ); 16 | 17 | CREATE OR REPLACE FUNCTION fn_prevent_update_secrets_data() 18 | RETURNS trigger AS 19 | $BODY$ 20 | BEGIN 21 | RAISE EXCEPTION 'cannot update immutable field'; 22 | END; 23 | $BODY$ 24 | LANGUAGE plpgsql VOLATILE 25 | COST 100; 26 | 27 | CREATE TRIGGER trg_prevent_update_secrets_data 28 | BEFORE UPDATE OF id, name, data, user_id 29 | ON Secrets 30 | FOR EACH ROW 31 | EXECUTE PROCEDURE fn_prevent_update_secrets_data(); 32 | -------------------------------------------------------------------------------- /proto/session.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package session; 4 | 5 | message LoginRequest { 6 | string ident = 1; 7 | string pwd = 2; 8 | string totp = 3; 9 | } 10 | 11 | message Empty {} 12 | 13 | service Session { 14 | rpc Login(LoginRequest) returns (Empty); 15 | rpc Logout(Empty) returns (Empty); 16 | } -------------------------------------------------------------------------------- /proto/user.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package user; 4 | 5 | message SignupRequest { 6 | string email = 1; 7 | string pwd = 2; 8 | } 9 | 10 | message ResetRequest { 11 | string email = 1; 12 | string pwd = 2; 13 | string totp = 3; 14 | } 15 | 16 | message DeleteRequest { 17 | string pwd = 1; 18 | string totp = 2; 19 | } 20 | 21 | message TotpRequest { 22 | enum actions { 23 | ENABLE = 0; 24 | DISABLE = 1; 25 | }; 26 | 27 | actions action = 1; 28 | string pwd = 2; 29 | string totp = 3; 30 | } 31 | 32 | message Empty {} 33 | 34 | service User { 35 | rpc Signup(SignupRequest) returns (Empty); 36 | rpc Reset(ResetRequest) returns (Empty); 37 | rpc Delete(DeleteRequest) returns (Empty); 38 | rpc Totp(TotpRequest) returns (Empty); 39 | } -------------------------------------------------------------------------------- /redis/redis.conf: -------------------------------------------------------------------------------- 1 | maxmemory 2mb 2 | maxmemory-policy allkeys-lru 3 | tracking-table-max-keys 1000000 -------------------------------------------------------------------------------- /scripts/build_db_setup_script.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import re 5 | import sys 6 | 7 | from dotenv import load_dotenv 8 | load_dotenv() 9 | 10 | WORKDIR = os.getenv("DB_MIGRATIONS_PATH") or "./migrations" 11 | TARGET = os.getenv("DB_SETUP_SCRIPT_PATH") or "./migrations/.postgres/setup.sql" 12 | REGEX = os.getenv("DB_FILE_REGEX") or "up.sql" 13 | 14 | regex = re.compile(REGEX) 15 | 16 | def main() -> int: 17 | print("Browsing for migration files at ...") 18 | 19 | target_dirname = os.path.dirname(TARGET) 20 | if not os.path.exists(target_dirname): 21 | os.mkdir(target_dirname) 22 | 23 | scripts = [] 24 | 25 | for root, _, files in os.walk(WORKDIR): 26 | files = filter(lambda filename: regex.match(filename), files) 27 | 28 | files = map(lambda filename: os.path.join(root, filename), files) 29 | scripts += list(files) 30 | 31 | if not scripts: 32 | print("No migration files where found") 33 | return 1 34 | 35 | target = open(TARGET, "w") 36 | for path in sorted(scripts): 37 | print(f"-\t{path}") 38 | 39 | fo = open(path, "r") 40 | content = fo.read() 41 | fo.close() 42 | 43 | target.write(f"{content}\n") 44 | 45 | target.close() 46 | return 0 47 | 48 | if __name__ == '__main__': 49 | sys.exit(main()) -------------------------------------------------------------------------------- /src/base64.rs: -------------------------------------------------------------------------------- 1 | //! Base64 related utilities like custom engines for specific encodings/decodings. 2 | 3 | use crate::result::{Error, Result}; 4 | use base64::{ 5 | alphabet, 6 | engine::{self, general_purpose}, 7 | Engine, 8 | }; 9 | 10 | /// An url safe implementation of [`base64::engine::Engine`] 11 | pub const B64_CUSTOM_ENGINE: engine::GeneralPurpose = 12 | engine::GeneralPurpose::new(&alphabet::URL_SAFE, general_purpose::NO_PAD); 13 | 14 | /// Decodes a b64 string 15 | pub fn decode_str(s: &str) -> Result { 16 | B64_CUSTOM_ENGINE 17 | .decode(s) 18 | .map_err(|err| { 19 | warn!(error = err.to_string(), "decoding base64 string"); 20 | Error::Unknown 21 | }) 22 | .and_then(|value| { 23 | String::from_utf8(value).map_err(|err| { 24 | warn!( 25 | error = err.to_string(), 26 | "getting string from base64 decoded data", 27 | ); 28 | 29 | Error::Unknown 30 | }) 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/bin/grpc.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate tracing; 3 | 4 | use rauth::{ 5 | config, 6 | metadata::repository::PostgresMetadataRepository, 7 | secret::repository::PostgresSecretRepository, 8 | session::{ 9 | application::SessionApplication, 10 | grpc::{SessionGrpcService, SessionServer}, 11 | }, 12 | smtp::Smtp, 13 | token::{application::TokenApplication, repository::RedisTokenRepository}, 14 | user::{ 15 | application::UserApplication, 16 | event_bus::RabbitMqUserBus, 17 | grpc::{UserGrpcService, UserServer}, 18 | repository::PostgresUserRepository, 19 | }, 20 | }; 21 | use std::sync::Arc; 22 | use std::time::Duration; 23 | use std::{error::Error, net::SocketAddr}; 24 | use tonic::transport::Server; 25 | 26 | #[tokio::main] 27 | async fn main() -> Result<(), Box> { 28 | let subscriber = tracing_subscriber::FmtSubscriber::new(); 29 | tracing::subscriber::set_global_default(subscriber)?; 30 | 31 | if let Err(err) = dotenv::dotenv() { 32 | warn!(error = err.to_string(), "processing dotenv file"); 33 | } 34 | 35 | let metadata_repo = Arc::new(PostgresMetadataRepository { 36 | pool: config::POSTGRES_POOL.get().await, 37 | }); 38 | 39 | let secret_repo = Arc::new(PostgresSecretRepository { 40 | pool: config::POSTGRES_POOL.get().await, 41 | metadata_repo: metadata_repo.clone(), 42 | }); 43 | 44 | let user_repo = Arc::new(PostgresUserRepository { 45 | pool: config::POSTGRES_POOL.get().await, 46 | metadata_repo: metadata_repo.clone(), 47 | }); 48 | 49 | let user_event_bus = Arc::new(RabbitMqUserBus { 50 | pool: config::RABBITMQ_POOL.get().await, 51 | exchange: &config::RABBITMQ_USERS_EXCHANGE, 52 | issuer: &config::EVENT_ISSUER, 53 | }); 54 | 55 | let token_repo = Arc::new(RedisTokenRepository { 56 | pool: &config::REDIS_POOL, 57 | }); 58 | let credentials = if config::SMTP_USERNAME.len() > 0 && config::SMTP_PASSWORD.len() > 0 { 59 | Some(( 60 | config::SMTP_USERNAME.to_string(), 61 | config::SMTP_PASSWORD.to_string(), 62 | )) 63 | } else { 64 | None 65 | }; 66 | 67 | let mailer = Smtp::new( 68 | &config::SMTP_ORIGIN, 69 | &config::SMTP_TEMPLATES, 70 | &config::SMTP_TRANSPORT, 71 | credentials, 72 | )? 73 | .with_issuer(&config::SMTP_ISSUER); 74 | 75 | let token_app = Arc::new(TokenApplication { 76 | token_repo: token_repo.clone(), 77 | timeout: Duration::from_secs(*config::TOKEN_TIMEOUT), 78 | token_issuer: &config::TOKEN_ISSUER, 79 | private_key: &config::JWT_SECRET, 80 | public_key: &config::JWT_PUBLIC, 81 | }); 82 | 83 | let user_app = UserApplication { 84 | user_repo: user_repo.clone(), 85 | secret_repo: secret_repo.clone(), 86 | token_app: token_app.clone(), 87 | mailer: Arc::new(mailer), 88 | event_bus: user_event_bus.clone(), 89 | totp_secret_len: *config::TOTP_SECRET_LEN, 90 | totp_secret_name: &config::TOTP_SECRET_NAME, 91 | pwd_sufix: &config::PWD_SUFIX, 92 | }; 93 | 94 | let user_grpc_service = UserGrpcService { 95 | user_app, 96 | jwt_header: &config::JWT_HEADER, 97 | totp_header: &config::TOTP_HEADER, 98 | }; 99 | 100 | let session_app = SessionApplication { 101 | user_repo: user_repo.clone(), 102 | secret_repo: secret_repo.clone(), 103 | token_app: token_app.clone(), 104 | totp_secret_name: &config::TOTP_SECRET_NAME, 105 | pwd_sufix: &config::PWD_SUFIX, 106 | }; 107 | 108 | let session_grpc_service = SessionGrpcService { 109 | session_app, 110 | jwt_header: &config::JWT_HEADER, 111 | }; 112 | 113 | let addr: SocketAddr = config::SERVER_ADDR.parse().unwrap(); 114 | info!( 115 | address = addr.to_string(), 116 | "server ready to accept connections" 117 | ); 118 | 119 | Server::builder() 120 | .add_service(UserServer::new(user_grpc_service)) 121 | .add_service(SessionServer::new(session_grpc_service)) 122 | .serve(addr) 123 | .await?; 124 | 125 | Ok(()) 126 | } 127 | -------------------------------------------------------------------------------- /src/bin/rest.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate tracing; 3 | 4 | use actix_web::web::Data; 5 | use actix_web::{middleware, App, HttpServer}; 6 | use rauth::{ 7 | config, 8 | session::rest::SessionRestService, 9 | token::{application::TokenApplication, repository::RedisTokenRepository}, 10 | }; 11 | use std::error::Error; 12 | use std::sync::Arc; 13 | use std::time::Duration; 14 | 15 | #[tokio::main] 16 | async fn main() -> Result<(), Box> { 17 | let subscriber = tracing_subscriber::FmtSubscriber::new(); 18 | tracing::subscriber::set_global_default(subscriber)?; 19 | 20 | if let Err(err) = dotenv::dotenv() { 21 | warn!(error = err.to_string(), "processing dotenv file",); 22 | } 23 | 24 | let token_repo = Arc::new(RedisTokenRepository { 25 | pool: &config::REDIS_POOL, 26 | }); 27 | 28 | let token_app = TokenApplication { 29 | token_repo: token_repo.clone(), 30 | timeout: Duration::from_secs(*config::TOKEN_TIMEOUT), 31 | token_issuer: &config::TOKEN_ISSUER, 32 | private_key: &config::JWT_SECRET, 33 | public_key: &config::JWT_PUBLIC, 34 | }; 35 | 36 | let session_server = Arc::new(SessionRestService { 37 | token_app, 38 | jwt_header: &config::JWT_HEADER, 39 | }); 40 | 41 | info!( 42 | address = *config::SERVER_ADDR, 43 | "server ready to accept connections" 44 | ); 45 | 46 | HttpServer::new(move || { 47 | App::new() 48 | .wrap(middleware::Logger::default()) 49 | .app_data(Data::new(session_server.clone())) 50 | .configure(session_server.router()) 51 | }) 52 | .bind(&*config::SERVER_ADDR)? 53 | .run() 54 | .await 55 | .unwrap(); 56 | 57 | Ok(()) 58 | } 59 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use async_once::AsyncOnce; 2 | use base64::{engine::general_purpose, Engine as _}; 3 | use deadpool_lapin::{Config, Pool, Runtime}; 4 | use lapin::{options, types::FieldTable, ExchangeKind}; 5 | use lazy_static::lazy_static; 6 | use reool::RedisPool; 7 | use sqlx::postgres::{PgPool, PgPoolOptions}; 8 | use std::env; 9 | use tokio::runtime::Handle; 10 | 11 | const DEFAULT_ADDR: &str = "127.0.0.1"; 12 | const DEFAULT_PORT: &str = "8000"; 13 | const DEFAULT_TEMPLATES_PATH: &str = "/etc/rauth/smtp/templates/*.html"; 14 | const DEFAULT_JWT_HEADER: &str = "authorization"; 15 | const DEFAULT_TOTP_HEADER: &str = "x-totp-secret"; 16 | const DEFAULT_TOKEN_TIMEOUT: u64 = 7200; 17 | const DEFAULT_POOL_SIZE: u32 = 10; 18 | const DEFAULT_TOTP_SECRET_LEN: usize = 32_usize; 19 | const DEFAULT_TOTP_SECRET_NAME: &str = "totp"; 20 | 21 | const ENV_SERVICE_PORT: &str = "SERVICE_PORT"; 22 | const ENV_SERVICE_ADDR: &str = "SERVICE_ADDR"; 23 | const ENV_POSTGRES_DSN: &str = "POSTGRES_DSN"; 24 | const ENV_POSTGRES_POOL: &str = "POSTGRES_POOL"; 25 | const ENV_JWT_SECRET: &str = "JWT_SECRET"; 26 | const ENV_JWT_PUBLIC: &str = "JWT_PUBLIC"; 27 | const ENV_JWT_HEADER: &str = "JWT_HEADER"; 28 | const ENV_TOTP_HEADER: &str = "TOTP_HEADER"; 29 | const ENV_REDIS_URL: &str = "REDIS_URL"; 30 | const ENV_REDIS_POOL: &str = "REDIS_POOL"; 31 | const ENV_TOKEN_TIMEOUT: &str = "TOKEN_TIMEOUT"; 32 | const ENV_SMTP_TRANSPORT: &str = "SMTP_TRANSPORT"; 33 | const ENV_SMTP_USERNAME: &str = "SMTP_USERNAME"; 34 | const ENV_SMTP_PASSWORD: &str = "SMTP_PASSWORD"; 35 | const ENV_SMTP_ISSUER: &str = "SMTP_ISSUER"; 36 | const ENV_SMTP_TEMPLATES: &str = "SMTP_TEMPLATES"; 37 | const ENV_SMTP_ORIGIN: &str = "SMTP_ORIGIN"; 38 | const ENV_PWD_SUFIX: &str = "PWD_SUFIX"; 39 | const ENV_RABBITMQ_USERS_EXCHANGE: &str = "RABBITMQ_USERS_EXCHANGE"; 40 | const ENV_RABBITMQ_URL: &str = "RABBITMQ_URL"; 41 | const ENV_RABBITMQ_POOL: &str = "RABBITMQ_POOL"; 42 | const ENV_EVENT_ISSUER: &str = "EVENT_ISSUER"; 43 | const ENV_TOTP_SECRET_LEN: &str = "TOTP_SECRET_LEN"; 44 | const ENV_TOTP_SECRET_NAME: &str = "TOTP_SECRET_NAME"; 45 | const ENV_TOKEN_ISSUER: &str = "TOKEN_ISSUER"; 46 | 47 | lazy_static! { 48 | pub static ref SERVER_ADDR: String = { 49 | let netw = env::var(ENV_SERVICE_ADDR).unwrap_or_else(|_| DEFAULT_ADDR.to_string()); 50 | let port = env::var(ENV_SERVICE_PORT).unwrap_or_else(|_| DEFAULT_PORT.to_string()); 51 | format!("{}:{}", netw, port) 52 | }; 53 | pub static ref TOKEN_TIMEOUT: u64 = env::var(ENV_TOKEN_TIMEOUT) 54 | .map(|timeout| timeout.parse().unwrap()) 55 | .unwrap_or(DEFAULT_TOKEN_TIMEOUT); 56 | pub static ref JWT_SECRET: Vec = env::var(ENV_JWT_SECRET) 57 | .map(|secret| general_purpose::STANDARD.decode(secret).unwrap()) 58 | .expect("jwt secret must be set"); 59 | pub static ref JWT_PUBLIC: Vec = env::var(ENV_JWT_PUBLIC) 60 | .map(|secret| general_purpose::STANDARD.decode(secret).unwrap()) 61 | .expect("jwt public key must be set"); 62 | pub static ref JWT_HEADER: String = 63 | env::var(ENV_JWT_HEADER).unwrap_or_else(|_| DEFAULT_JWT_HEADER.to_string()); 64 | pub static ref TOTP_HEADER: String = 65 | env::var(ENV_TOTP_HEADER).unwrap_or_else(|_| DEFAULT_TOTP_HEADER.to_string()); 66 | pub static ref SMTP_TRANSPORT: String = 67 | env::var(ENV_SMTP_TRANSPORT).expect("smtp transport must be set"); 68 | pub static ref SMTP_USERNAME: String = env::var(ENV_SMTP_USERNAME).unwrap_or_default(); 69 | pub static ref SMTP_PASSWORD: String = env::var(ENV_SMTP_PASSWORD).unwrap_or_default(); 70 | pub static ref SMTP_ORIGIN: String = 71 | env::var(ENV_SMTP_ORIGIN).expect("smpt origin must be set"); 72 | pub static ref SMTP_ISSUER: String = 73 | env::var(ENV_SMTP_ISSUER).expect("smtp issuer must be set"); 74 | pub static ref SMTP_TEMPLATES: String = 75 | env::var(ENV_SMTP_TEMPLATES).unwrap_or_else(|_| DEFAULT_TEMPLATES_PATH.to_string()); 76 | pub static ref PWD_SUFIX: String = env::var(ENV_PWD_SUFIX).expect("password sufix must be set"); 77 | pub static ref POSTGRES_POOL: AsyncOnce = AsyncOnce::new(async { 78 | let postgres_dsn = env::var(ENV_POSTGRES_DSN).expect("postgres dns must be set"); 79 | 80 | let postgres_pool = env::var(ENV_POSTGRES_POOL) 81 | .map(|pool_size| pool_size.parse().unwrap()) 82 | .unwrap_or(DEFAULT_POOL_SIZE); 83 | 84 | PgPoolOptions::new() 85 | .max_connections(postgres_pool) 86 | .connect(&postgres_dsn) 87 | .await 88 | .unwrap() 89 | }); 90 | pub static ref REDIS_POOL: RedisPool = { 91 | let redis_url: String = env::var(ENV_REDIS_URL).expect("redis url must be set"); 92 | let redis_pool: usize = env::var(ENV_REDIS_POOL) 93 | .map(|pool_size| pool_size.parse().unwrap()) 94 | .unwrap_or_else(|_| DEFAULT_POOL_SIZE.try_into().unwrap()); 95 | 96 | RedisPool::builder() 97 | .connect_to_node(redis_url) 98 | .desired_pool_size(redis_pool) 99 | .task_executor(Handle::current()) 100 | .finish_redis_rs() 101 | .unwrap() 102 | }; 103 | pub static ref RABBITMQ_USERS_EXCHANGE: String = 104 | env::var(ENV_RABBITMQ_USERS_EXCHANGE).expect("rabbitmq users bus name must be set"); 105 | pub static ref RABBITMQ_POOL: AsyncOnce = AsyncOnce::new(async { 106 | let rabbitmq_url = env::var(ENV_RABBITMQ_URL).expect("rabbitmq url must be set"); 107 | let rabbitmq_pool = env::var(ENV_RABBITMQ_POOL) 108 | .map(|pool_size| pool_size.parse().unwrap()) 109 | .unwrap_or_else(|_| DEFAULT_POOL_SIZE.try_into().unwrap()); 110 | 111 | let pool = Config { 112 | url: Some(rabbitmq_url), 113 | ..Default::default() 114 | } 115 | .builder(Some(Runtime::Tokio1)) 116 | .max_size(rabbitmq_pool) 117 | .build() 118 | .unwrap(); 119 | 120 | let channel = pool.get().await.unwrap().create_channel().await.unwrap(); 121 | 122 | let exchange_options = options::ExchangeDeclareOptions { 123 | durable: true, 124 | auto_delete: false, 125 | internal: false, 126 | nowait: false, 127 | passive: false, 128 | }; 129 | 130 | channel 131 | .exchange_declare( 132 | &RABBITMQ_USERS_EXCHANGE, 133 | ExchangeKind::Fanout, 134 | exchange_options, 135 | FieldTable::default(), 136 | ) 137 | .await 138 | .unwrap(); 139 | 140 | pool 141 | }); 142 | pub static ref EVENT_ISSUER: String = 143 | env::var(ENV_EVENT_ISSUER).expect("event issuer must be set"); 144 | pub static ref TOTP_SECRET_LEN: usize = env::var(ENV_TOTP_SECRET_LEN) 145 | .map(|len| len.parse().unwrap()) 146 | .unwrap_or_else(|_| DEFAULT_TOTP_SECRET_LEN); 147 | pub static ref TOTP_SECRET_NAME: String = 148 | env::var(ENV_TOTP_SECRET_NAME).unwrap_or_else(|_| DEFAULT_TOTP_SECRET_NAME.to_string()); 149 | pub static ref TOKEN_ISSUER: String = 150 | env::var(ENV_TOKEN_ISSUER).expect("token issuer must be set"); 151 | } 152 | -------------------------------------------------------------------------------- /src/crypto.rs: -------------------------------------------------------------------------------- 1 | //! Criptography utilities for the validation and generation of JWTs as well as RSA encription and decription. 2 | 3 | use crate::result::{Error, Result}; 4 | use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; 5 | use libreauth::{ 6 | hash::HashFunction::Sha256, 7 | oath::{TOTPBuilder, TOTP}, 8 | }; 9 | use openssl::{ 10 | encrypt::{Decrypter, Encrypter}, 11 | pkey::PKey, 12 | rsa::Padding, 13 | }; 14 | use rand::prelude::*; 15 | use serde::{de::DeserializeOwned, Serialize}; 16 | 17 | const SECURE_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ 18 | abcdefghijklmnopqrstuvwxyz\ 19 | 0123456789"; 20 | 21 | /// Given an elliptic curve secret in PEM format returns the resulting string of signing the provided 22 | /// payload in a JWT format. 23 | pub fn sign_jwt(secret: &[u8], payload: S) -> Result { 24 | let header = Header::new(Algorithm::ES256); 25 | let key = EncodingKey::from_ec_pem(secret).map_err(|err| { 26 | error!(error = err.to_string(), "encoding elliptic curve keypair",); 27 | Error::Unknown 28 | })?; 29 | 30 | let token = jsonwebtoken::encode(&header, &payload, &key).map_err(|err| { 31 | error!(error = err.to_string(), "signing json web token"); 32 | Error::Unknown 33 | })?; 34 | 35 | Ok(token) 36 | } 37 | 38 | /// Given an elliptic curve secret in PEM format returns the token's claim if, and only if, the provided token 39 | /// is valid. Otherwise an error is returned. 40 | pub fn decode_jwt(public: &[u8], token: &str) -> Result { 41 | let validation = Validation::new(Algorithm::ES256); 42 | let key = DecodingKey::from_ec_pem(public).map_err(|err| { 43 | error!(error = err.to_string(), "decoding elliptic curve keypair",); 44 | Error::Unknown 45 | })?; 46 | 47 | let token = jsonwebtoken::decode::(token, &key, &validation).map_err(|err| { 48 | error!(error = err.to_string(), "checking token's signature",); 49 | Error::InvalidToken 50 | })?; 51 | 52 | Ok(token.claims) 53 | } 54 | 55 | /// Returns an url safe random string. 56 | pub fn get_random_string(size: usize) -> String { 57 | let token: String = (0..size) 58 | .map(|_| { 59 | let mut rand = rand::thread_rng(); 60 | let idx = rand.gen_range(0..SECURE_CHARSET.len()); 61 | SECURE_CHARSET[idx] as char 62 | }) 63 | .collect(); 64 | 65 | token 66 | } 67 | 68 | /// Given an array of bytes to use as secret, generates a TOTP instance. 69 | pub fn generate_totp(secret: &[u8]) -> Result { 70 | TOTPBuilder::new() 71 | .key(secret) 72 | .hash_function(Sha256) 73 | .finalize() 74 | .map_err(|err| { 75 | error!( 76 | error = err.to_string(), 77 | "genereting time-based one time password", 78 | ); 79 | Error::Unknown 80 | }) 81 | } 82 | 83 | /// Given an array of bytes to use as TOTP's secret and a candidate of pwd, returns true if, and only if, pwd 84 | /// has the same value as the TOTP. 85 | pub fn verify_totp(secret: &[u8], pwd: &str) -> Result { 86 | let totp = generate_totp(secret)?; 87 | Ok(totp.is_valid(pwd)) 88 | } 89 | 90 | /// Given a subject str and a sufix returns the sha256 digest of apending them both. 91 | pub fn obfuscate(subject: &str, sufix: &str) -> String { 92 | let format_pwd = format!("{}{}", subject, sufix); 93 | return sha256::digest(format_pwd.as_bytes()); 94 | } 95 | 96 | /// Given a RSA public key in PEM format returns the value of data encrypted by that key, 97 | pub fn _encrypt(public: &[u8], data: &[u8]) -> Result> { 98 | let pkey = PKey::public_key_from_pem(public).map_err(|err| { 99 | error!(error = err.to_string(), "parsing public key from pem"); 100 | Error::Unknown 101 | })?; 102 | 103 | let mut encrypter = Encrypter::new(&pkey).map_err(|err| { 104 | error!(error = err.to_string(), "building encrypter"); 105 | Error::Unknown 106 | })?; 107 | 108 | encrypter.set_rsa_padding(Padding::PKCS1).map_err(|err| { 109 | error!(error = err.to_string(), "setting up rsa padding"); 110 | Error::Unknown 111 | })?; 112 | 113 | let buffer_len = encrypter.encrypt_len(data).map_err(|err| { 114 | error!(error = err.to_string(), "computing encription length"); 115 | Error::Unknown 116 | })?; 117 | 118 | let mut encrypted = vec![0; buffer_len]; 119 | 120 | let encrypted_len = encrypter.encrypt(data, &mut encrypted).map_err(|err| { 121 | error!(error = err.to_string(), "encripting data"); 122 | Error::Unknown 123 | })?; 124 | 125 | encrypted.truncate(encrypted_len); 126 | Ok(encrypted) 127 | } 128 | 129 | /// Given a RSA private key in PEM format returns the value of data decrypted by that key. 130 | pub fn _decrypt(private: &[u8], data: &[u8]) -> Result> { 131 | let key = PKey::private_key_from_pem(private).map_err(|err| { 132 | error!(error = err.to_string(), "parsing private key from pem"); 133 | Error::Unknown 134 | })?; 135 | 136 | let mut decrypter = Decrypter::new(&key).map_err(|err| { 137 | error!(error = err.to_string(), "building decrypter"); 138 | Error::Unknown 139 | })?; 140 | 141 | decrypter.set_rsa_padding(Padding::PKCS1).map_err(|err| { 142 | error!(error = err.to_string(), "setting up rsa padding"); 143 | Error::Unknown 144 | })?; 145 | 146 | let buffer_len = decrypter.decrypt_len(data).map_err(|err| { 147 | error!(error = err.to_string(), "computing decryption length"); 148 | Error::Unknown 149 | })?; 150 | 151 | let mut decrypted = vec![0; buffer_len]; 152 | 153 | let decrypted_len = decrypter.decrypt(data, &mut decrypted).map_err(|err| { 154 | error!(error = err.to_string(), "decrypting data"); 155 | Error::Unknown 156 | })?; 157 | 158 | decrypted.truncate(decrypted_len); 159 | Ok((*decrypted).to_vec()) 160 | } 161 | 162 | #[cfg(test)] 163 | pub mod tests { 164 | use super::{generate_totp, verify_totp}; 165 | 166 | #[test] 167 | fn verify_totp_ok_should_not_fail() { 168 | const SECRET: &[u8] = "hello world".as_bytes(); 169 | 170 | let code = generate_totp(SECRET).unwrap().generate(); 171 | 172 | assert_eq!(code.len(), 6); 173 | assert!(verify_totp(SECRET, &code).is_ok()); 174 | } 175 | 176 | #[test] 177 | fn verify_totp_ko_should_not_fail() { 178 | const SECRET: &[u8] = "hello world".as_bytes(); 179 | assert!(!verify_totp(SECRET, "tester").unwrap()); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/email.rs: -------------------------------------------------------------------------------- 1 | //! Methods for managing email data 2 | 3 | const DOMAIN_SEPARATOR: &str = "@"; 4 | const SUFIX_SEPARATOR: &str = "+"; 5 | 6 | /// Given an email that may, or may not, be sufixed, returns the actual email without the sufix. 7 | pub fn actual_email(email: &str) -> String { 8 | if !email.contains(SUFIX_SEPARATOR) { 9 | return email.to_string(); 10 | } 11 | 12 | let email_parts: Vec<&str> = email.split(SUFIX_SEPARATOR).collect(); 13 | let domain = email_parts 14 | .get(1) 15 | .and_then(|sufix| sufix.split(DOMAIN_SEPARATOR).nth(1)) 16 | .unwrap_or_default(); 17 | 18 | email_parts 19 | .first() 20 | .cloned() 21 | .map(|username| vec![username, domain].join(DOMAIN_SEPARATOR)) 22 | .unwrap_or_default() 23 | } 24 | 25 | #[cfg(test)] 26 | pub mod tests { 27 | #[test] 28 | fn actual_email_should_not_fail() { 29 | struct Test<'a> { 30 | email: &'a str, 31 | actual_email: &'a str, 32 | } 33 | 34 | vec![ 35 | Test { 36 | email: "username@domain.com", 37 | actual_email: "username@domain.com", 38 | }, 39 | Test { 40 | email: "username+sufix@domain.com", 41 | actual_email: "username@domain.com", 42 | }, 43 | Test { 44 | email: "username+@domain.com", 45 | actual_email: "username@domain.com", 46 | }, 47 | ] 48 | .iter() 49 | .for_each(|test| { 50 | assert_eq!( 51 | super::actual_email(test.email), 52 | test.actual_email.to_string() 53 | ) 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/grpc.rs: -------------------------------------------------------------------------------- 1 | //! gRPC utilities for managing request's headers. 2 | 3 | use crate::base64; 4 | use tonic::{Request, Status}; 5 | 6 | use crate::result::Error; 7 | 8 | impl From for Status { 9 | fn from(value: Error) -> Self { 10 | match value { 11 | Error::Unknown => Status::unknown(value), 12 | Error::NotFound => Status::not_found(value), 13 | Error::NotAvailable => Status::unavailable(value), 14 | Error::Unauthorized => Status::permission_denied(value), 15 | Error::InvalidToken | Error::InvalidFormat | Error::InvalidHeader => { 16 | Status::invalid_argument(value) 17 | } 18 | Error::WrongCredentials => Status::unauthenticated(value), 19 | Error::RegexNotMatch => Status::failed_precondition(value), 20 | } 21 | } 22 | } 23 | 24 | /// Given a gPRC request, returns the value of the provided header's key if any, otherwise an error 25 | /// is returned. 26 | pub fn get_header(req: &Request, header: &str) -> Result { 27 | let data = req 28 | .metadata() 29 | .get(header) 30 | .ok_or_else(|| Into::::into(Error::NotFound)) 31 | .map(|data| data.to_str())?; 32 | 33 | data.map(|data| data.to_string()).map_err(|err| { 34 | warn!(error = err.to_string(), "parsing header data to str",); 35 | Error::InvalidHeader.into() 36 | }) 37 | } 38 | 39 | /// Given a gPRC request, returns the base64 decoded value of the provided header's key if any, otherwise 40 | /// an error is returned. 41 | pub fn get_encoded_header(request: &Request, header: &str) -> Result { 42 | let header = get_header(request, header)?; 43 | base64::decode_str(&header).map_err(|err| { 44 | warn!(error = err.to_string(), "decoding header from base64"); 45 | Error::InvalidHeader.into() 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /src/http.rs: -------------------------------------------------------------------------------- 1 | use crate::base64; 2 | use crate::result::{Error, Result}; 3 | use actix_web::{HttpRequest, HttpResponse}; 4 | 5 | impl From for HttpResponse { 6 | fn from(value: Error) -> Self { 7 | match value { 8 | Error::Unknown => HttpResponse::InternalServerError().body(value.to_string()), 9 | Error::NotFound => HttpResponse::NotFound().finish(), 10 | Error::NotAvailable => HttpResponse::ServiceUnavailable().finish(), 11 | Error::Unauthorized => HttpResponse::Unauthorized().finish(), 12 | Error::InvalidToken | Error::InvalidFormat | Error::InvalidHeader => { 13 | HttpResponse::BadRequest().body(value.to_string()) 14 | } 15 | 16 | Error::WrongCredentials => HttpResponse::Forbidden().finish(), 17 | Error::RegexNotMatch => HttpResponse::NotAcceptable().finish(), 18 | } 19 | } 20 | } 21 | 22 | fn get_header(req: HttpRequest, header: &str) -> Result { 23 | req.headers() 24 | .get(header) 25 | .ok_or(Error::NotFound) 26 | .and_then(|header| { 27 | header.to_str().map_err(|err| { 28 | warn!(error = err.to_string(), "parsing header data to str",); 29 | Error::InvalidHeader 30 | }) 31 | }) 32 | .map(Into::into) 33 | } 34 | 35 | /// Given an http request, returns the base64 decoded value of the provided header's key if any, otherwise 36 | /// an error is returned. 37 | pub fn get_encoded_header(req: HttpRequest, header: &str) -> Result { 38 | let header = get_header(req, header)?; 39 | base64::decode_str(&header) 40 | } 41 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate tracing; 3 | #[macro_use] 4 | extern crate serde; 5 | 6 | #[cfg(feature = "config")] 7 | pub mod config; 8 | pub mod metadata; 9 | pub mod secret; 10 | pub mod session; 11 | pub mod smtp; 12 | pub mod token; 13 | pub mod user; 14 | 15 | mod base64; 16 | mod crypto; 17 | mod email; 18 | #[cfg(feature = "grpc")] 19 | mod grpc; 20 | #[cfg(feature = "rest")] 21 | mod http; 22 | mod rabbitmq; 23 | mod regex; 24 | mod result; 25 | mod time; 26 | -------------------------------------------------------------------------------- /src/metadata/application.rs: -------------------------------------------------------------------------------- 1 | use super::domain::Metadata; 2 | use crate::result::Result; 3 | use async_trait::async_trait; 4 | 5 | #[async_trait] 6 | pub trait MetadataRepository { 7 | async fn find(&self, id: i32) -> Result; 8 | async fn create(&self, meta: &mut Metadata) -> Result<()>; 9 | async fn save(&self, meta: &Metadata) -> Result<()>; 10 | async fn delete(&self, meta: &Metadata) -> Result<()>; 11 | } 12 | 13 | #[cfg(test)] 14 | pub mod tests { 15 | use super::super::domain::{tests::new_metadata, Metadata}; 16 | use super::MetadataRepository; 17 | use crate::result::Result; 18 | use async_trait::async_trait; 19 | 20 | type MockFnFind = Option Result>; 21 | 22 | type MockFnCreate = 23 | Option Result<()>>; 24 | 25 | type MockFnSave = Option Result<()>>; 26 | 27 | type MockFnDelete = Option Result<()>>; 28 | 29 | #[derive(Default)] 30 | pub struct MetadataRepositoryMock { 31 | pub fn_find: MockFnFind, 32 | pub fn_create: MockFnCreate, 33 | pub fn_save: MockFnSave, 34 | pub fn_delete: MockFnDelete, 35 | } 36 | 37 | #[async_trait] 38 | impl MetadataRepository for MetadataRepositoryMock { 39 | async fn find(&self, id: i32) -> Result { 40 | if let Some(f) = self.fn_find { 41 | return f(self, id); 42 | } 43 | 44 | Ok(new_metadata()) 45 | } 46 | 47 | async fn create(&self, meta: &mut Metadata) -> Result<()> { 48 | if let Some(f) = self.fn_create { 49 | return f(self, meta); 50 | } 51 | 52 | Ok(()) 53 | } 54 | 55 | async fn save(&self, meta: &Metadata) -> Result<()> { 56 | if let Some(f) = self.fn_save { 57 | return f(self, meta); 58 | } 59 | 60 | Ok(()) 61 | } 62 | 63 | async fn delete(&self, meta: &Metadata) -> Result<()> { 64 | if let Some(f) = self.fn_delete { 65 | return f(self, meta); 66 | } 67 | 68 | Ok(()) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/metadata/domain.rs: -------------------------------------------------------------------------------- 1 | use chrono::naive::NaiveDateTime; 2 | use chrono::Utc; 3 | 4 | #[derive(Debug, Clone)] 5 | pub struct Metadata { 6 | pub id: i32, 7 | pub created_at: NaiveDateTime, 8 | pub updated_at: NaiveDateTime, 9 | pub deleted_at: Option, 10 | } 11 | 12 | impl Metadata { 13 | pub fn get_id(&self) -> i32 { 14 | self.id 15 | } 16 | 17 | pub fn touch(&mut self) { 18 | self.updated_at = Utc::now().naive_utc(); 19 | } 20 | } 21 | 22 | impl Default for Metadata { 23 | fn default() -> Self { 24 | Self { 25 | id: 0, 26 | created_at: Utc::now().naive_utc(), 27 | updated_at: Utc::now().naive_utc(), 28 | deleted_at: None, 29 | } 30 | } 31 | } 32 | 33 | #[cfg(test)] 34 | pub mod tests { 35 | use super::Metadata; 36 | use chrono::Utc; 37 | 38 | pub fn new_metadata() -> Metadata { 39 | Metadata { 40 | id: 999, 41 | ..Default::default() 42 | } 43 | } 44 | 45 | #[test] 46 | fn metadata_new_should_not_fail() { 47 | let before = Utc::now().naive_utc(); 48 | let meta = Metadata::default(); 49 | let after = Utc::now().naive_utc(); 50 | 51 | assert_eq!(meta.id, 0); 52 | assert!(meta.created_at >= before && meta.created_at <= after); 53 | assert!(meta.updated_at >= before && meta.updated_at <= after); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/metadata/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod application; 2 | pub mod domain; 3 | #[cfg(feature = "postgres")] 4 | pub mod repository; 5 | -------------------------------------------------------------------------------- /src/metadata/repository.rs: -------------------------------------------------------------------------------- 1 | use super::{application::MetadataRepository, domain::Metadata}; 2 | use crate::result::{Error, Result}; 3 | use async_trait::async_trait; 4 | use chrono::naive::NaiveDateTime; 5 | use sqlx::postgres::PgPool; 6 | 7 | const QUERY_INSERT_METADATA: &str = 8 | "INSERT INTO metadata (created_at, updated_at, deleted_at) VALUES ($1, $2, $3) RETURNING id"; 9 | const QUERY_FIND_METADATA: &str = 10 | "SELECT id, created_at, updated_at, deleted_at FROM metadata WHERE id = $1"; 11 | const QUERY_UPDATE_METADATA: &str = 12 | "UPDATE metadata SET created_at = $2, updated_at = $3, deleted_at = $4 FROM metadata WHERE id = $1"; 13 | const QUERY_DELETE_METADATA: &str = "DELETE FROM metadata WHERE id = $1"; 14 | 15 | type PostgresSecretRow = (i32, NaiveDateTime, NaiveDateTime, Option); // id, created_at, updated_at, deleted_at 16 | 17 | pub struct PostgresMetadataRepository<'a> { 18 | pub pool: &'a PgPool, 19 | } 20 | 21 | #[async_trait] 22 | impl<'a> MetadataRepository for PostgresMetadataRepository<'a> { 23 | #[instrument(skip(self))] 24 | async fn find(&self, target: i32) -> Result { 25 | let row: PostgresSecretRow = { 26 | // block is required because of connection release 27 | sqlx::query_as(QUERY_FIND_METADATA) 28 | .bind(target) 29 | .fetch_one(self.pool) 30 | .await 31 | .map_err(|err| { 32 | error!( 33 | error = err.to_string(), 34 | "performing select by id query on postgres", 35 | ); 36 | Error::Unknown 37 | })? 38 | }; 39 | 40 | if row.0 == 0 { 41 | return Err(Error::NotFound); 42 | } 43 | 44 | Ok(Metadata { 45 | id: row.0, 46 | created_at: row.1, 47 | updated_at: row.2, 48 | deleted_at: row.3, 49 | }) 50 | } 51 | 52 | #[instrument(skip(self))] 53 | async fn create(&self, meta: &mut Metadata) -> Result<()> { 54 | let row: (i32,) = sqlx::query_as(QUERY_INSERT_METADATA) 55 | .bind(meta.created_at) 56 | .bind(meta.updated_at) 57 | .bind(meta.deleted_at) 58 | .fetch_one(self.pool) 59 | .await 60 | .map_err(|err| { 61 | error!( 62 | error = err.to_string(), 63 | "performing insert query on postgres", 64 | ); 65 | Error::Unknown 66 | })?; 67 | 68 | meta.id = row.0; 69 | Ok(()) 70 | } 71 | 72 | #[instrument(skip(self))] 73 | async fn save(&self, meta: &Metadata) -> Result<()> { 74 | sqlx::query(QUERY_UPDATE_METADATA) 75 | .bind(meta.id) 76 | .bind(meta.created_at) 77 | .bind(meta.updated_at) 78 | .bind(meta.deleted_at) 79 | .fetch_one(self.pool) 80 | .await 81 | .map_err(|err| { 82 | error!( 83 | error = err.to_string(), 84 | "performing update query on postgres", 85 | ); 86 | Error::Unknown 87 | })?; 88 | 89 | Ok(()) 90 | } 91 | 92 | #[instrument(skip(self))] 93 | async fn delete(&self, meta: &Metadata) -> Result<()> { 94 | sqlx::query(QUERY_DELETE_METADATA) 95 | .bind(meta.id) 96 | .fetch_one(self.pool) 97 | .await 98 | .map_err(|err| { 99 | error!( 100 | error = err.to_string(), 101 | "performing delete query on postgres", 102 | ); 103 | Error::Unknown 104 | })?; 105 | 106 | Ok(()) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/rabbitmq.rs: -------------------------------------------------------------------------------- 1 | //! RabbitMQ utilities for managing events handlering and emitions. 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | /// Represents all the possible kind of events that may be handled or emited. 6 | #[derive(Serialize, Deserialize)] 7 | #[serde(rename_all = "snake_case")] 8 | pub enum EventKind { 9 | Created, 10 | Deleted, 11 | } 12 | -------------------------------------------------------------------------------- /src/regex.rs: -------------------------------------------------------------------------------- 1 | //! A bunch of regex definitions and utilities. 2 | 3 | use crate::result::{Error, Result}; 4 | use regex::Regex; 5 | 6 | // include '+' into the charset before '@' in order to allow sufixed emails 7 | pub const EMAIL: &str = r"^[a-zA-Z0-9+._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$"; 8 | pub const BASE64: &str = r"^[A-Fa-f0-9]{8,64}$"; 9 | 10 | /// Returns ok if, and only if, the given string s matches the provided regex. 11 | pub fn match_regex(r: &str, s: &str) -> Result<()> { 12 | let regex = Regex::new(r).map_err(|err| { 13 | error!(error = err.to_string(), "building regex"); 14 | Error::Unknown 15 | })?; 16 | 17 | if !regex.is_match(s) { 18 | return Err(Error::RegexNotMatch); 19 | } 20 | 21 | Ok(()) 22 | } 23 | -------------------------------------------------------------------------------- /src/result.rs: -------------------------------------------------------------------------------- 1 | //! Custom result and common errors thrown by the application. 2 | 3 | /// Result represents a custom result where error is of the [`Error`] type. 4 | pub type Result = std::result::Result; 5 | 6 | /// StdResult is an alias for [`std::result::Result`] where error impl the [`std::error::Error`] trait. 7 | pub type StdResult = std::result::Result>; 8 | 9 | /// Error represent an error thrown by the application 10 | #[derive(strum_macros::Display, Debug, PartialEq)] 11 | pub enum Error { 12 | #[strum(serialize = "E001")] 13 | Unknown, 14 | #[strum(serialize = "E002")] 15 | NotFound, 16 | #[strum(serialize = "E003")] 17 | NotAvailable, 18 | #[strum(serialize = "E004")] 19 | Unauthorized, 20 | #[strum(serialize = "E005")] 21 | InvalidToken, 22 | #[strum(serialize = "E006")] 23 | InvalidFormat, 24 | #[strum(serialize = "E007")] 25 | InvalidHeader, 26 | #[strum(serialize = "E008")] 27 | WrongCredentials, 28 | #[strum(serialize = "E009")] 29 | RegexNotMatch, 30 | } 31 | 32 | impl From for String { 33 | fn from(val: Error) -> Self { 34 | val.to_string() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/secret/application.rs: -------------------------------------------------------------------------------- 1 | use super::domain::Secret; 2 | use crate::result::Result; 3 | use async_trait::async_trait; 4 | 5 | #[async_trait] 6 | pub trait SecretRepository { 7 | async fn find(&self, id: i32) -> Result; 8 | async fn find_by_user_and_name(&self, user: i32, name: &str) -> Result; 9 | async fn create(&self, secret: &mut Secret) -> Result<()>; 10 | async fn save(&self, secret: &Secret) -> Result<()>; 11 | async fn delete(&self, secret: &Secret) -> Result<()>; 12 | } 13 | 14 | #[cfg(test)] 15 | pub mod tests { 16 | use super::super::domain::{tests::new_secret, Secret}; 17 | use super::SecretRepository; 18 | use crate::result::Result; 19 | use async_trait::async_trait; 20 | 21 | type MockFnFind = Option Result>; 22 | type MockFnFindByUserAndName = 23 | Option Result>; 24 | type MockFnCreate = Option Result<()>>; 25 | type MockFnSave = Option Result<()>>; 26 | type MockFnDelete = Option Result<()>>; 27 | 28 | #[derive(Default)] 29 | pub struct SecretRepositoryMock { 30 | pub fn_find: MockFnFind, 31 | pub fn_find_by_user_and_name: MockFnFindByUserAndName, 32 | pub fn_create: MockFnCreate, 33 | pub fn_save: MockFnSave, 34 | pub fn_delete: MockFnDelete, 35 | } 36 | 37 | #[async_trait] 38 | impl SecretRepository for SecretRepositoryMock { 39 | #[instrument(skip(self))] 40 | async fn find(&self, id: i32) -> Result { 41 | if let Some(f) = self.fn_find { 42 | return f(self, id); 43 | } 44 | 45 | Ok(new_secret()) 46 | } 47 | 48 | #[instrument(skip(self))] 49 | async fn find_by_user_and_name(&self, user: i32, name: &str) -> Result { 50 | if let Some(f) = self.fn_find_by_user_and_name { 51 | return f(self, user, name); 52 | } 53 | 54 | Ok(new_secret()) 55 | } 56 | 57 | #[instrument(skip(self))] 58 | async fn create(&self, secret: &mut Secret) -> Result<()> { 59 | if let Some(f) = self.fn_create { 60 | return f(self, secret); 61 | } 62 | 63 | Ok(()) 64 | } 65 | 66 | #[instrument(skip(self))] 67 | async fn save(&self, secret: &Secret) -> Result<()> { 68 | if let Some(f) = self.fn_save { 69 | return f(self, secret); 70 | } 71 | 72 | Ok(()) 73 | } 74 | 75 | #[instrument(skip(self))] 76 | async fn delete(&self, secret: &Secret) -> Result<()> { 77 | if let Some(f) = self.fn_delete { 78 | return f(self, secret); 79 | } 80 | 81 | Ok(()) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/secret/domain.rs: -------------------------------------------------------------------------------- 1 | use crate::metadata::domain::Metadata; 2 | use crate::user::domain::User; 3 | use chrono::naive::NaiveDateTime; 4 | 5 | #[derive(Debug, Clone)] 6 | pub struct Secret { 7 | pub(super) id: i32, 8 | pub(super) owner: i32, 9 | pub(super) name: String, 10 | pub(super) data: Vec, 11 | pub(super) meta: Metadata, 12 | } 13 | 14 | impl Secret { 15 | pub fn new(user: &User, name: &str, data: &[u8]) -> Self { 16 | Secret { 17 | id: 0, 18 | owner: user.get_id(), 19 | name: name.to_string(), 20 | data: data.to_vec(), 21 | meta: Metadata::default(), 22 | } 23 | } 24 | 25 | pub fn get_data(&self) -> &[u8] { 26 | &self.data 27 | } 28 | 29 | pub fn get_id(&self) -> i32 { 30 | self.id 31 | } 32 | 33 | pub fn is_deleted(&self) -> bool { 34 | self.meta.deleted_at.is_some() 35 | } 36 | 37 | pub fn set_deleted_at(&mut self, deleted_at: Option) { 38 | self.meta.deleted_at = deleted_at; 39 | } 40 | } 41 | 42 | #[cfg(test)] 43 | pub mod tests { 44 | use super::Secret; 45 | use crate::metadata::domain::Metadata; 46 | use crate::user::domain::tests::new_user; 47 | 48 | pub const TEST_DEFAULT_SECRET_NAME: &str = "dummysecret"; 49 | pub const TEST_DEFAULT_SECRET_DATA: &str = "this is a secret"; 50 | 51 | pub fn new_secret() -> Secret { 52 | let inner_meta = Metadata::default(); 53 | 54 | Secret { 55 | id: 999_i32, 56 | owner: 0_i32, 57 | name: TEST_DEFAULT_SECRET_NAME.to_string(), 58 | data: TEST_DEFAULT_SECRET_DATA.as_bytes().to_vec(), 59 | meta: inner_meta, 60 | } 61 | } 62 | 63 | #[test] 64 | fn secret_new_should_not_fail() { 65 | let name = "dummy secret"; 66 | let data = "secret_new_should_success".as_bytes(); 67 | let user = new_user(); 68 | let secret = Secret::new(&user, name, data); 69 | 70 | assert_eq!(0, secret.id); 71 | assert_eq!(name, secret.name); 72 | assert_eq!(data, secret.data); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/secret/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod application; 2 | pub mod domain; 3 | #[cfg(feature = "postgres")] 4 | pub mod repository; 5 | -------------------------------------------------------------------------------- /src/secret/repository.rs: -------------------------------------------------------------------------------- 1 | use super::{application::SecretRepository, domain::Secret}; 2 | use crate::metadata::application::MetadataRepository; 3 | use crate::result::{Error, Result}; 4 | use async_trait::async_trait; 5 | use sqlx::error::Error as SqlError; 6 | use sqlx::postgres::PgPool; 7 | use std::sync::Arc; 8 | 9 | const QUERY_INSERT_SECRET: &str = 10 | "INSERT INTO secrets (name, data, user_id, meta_id) VALUES ($1, $2, $3, $4) RETURNING id"; 11 | const QUERY_FIND_SECRET: &str = 12 | "SELECT id, name, data, user_id, meta_id FROM secrets WHERE id = $1"; 13 | const QUERY_FIND_SECRET_BY_USER_AND_NAME: &str = 14 | "SELECT id, name, data, user_id, meta_id FROM secrets WHERE user_id = $1 AND name = $2"; 15 | const QUERY_UPDATE_SECRET: &str = 16 | "UPDATE secrets SET name = $2, data = $3, user_id = $4, meta_id = $5, password = $4 FROM secrets WHERE id = $1"; 17 | const QUERY_DELETE_SECRET: &str = "DELETE FROM secrets WHERE id = $1"; 18 | 19 | type PostgresSecretRow = (i32, String, String, i32, i32); // id, name, data, user_id, meta_id 20 | 21 | pub struct PostgresSecretRepository<'a, M: MetadataRepository> { 22 | pub pool: &'a PgPool, 23 | pub metadata_repo: Arc, 24 | } 25 | 26 | impl<'a, M: MetadataRepository> PostgresSecretRepository<'a, M> { 27 | async fn build(&self, secret_row: &PostgresSecretRow) -> Result { 28 | let meta = self.metadata_repo.find(secret_row.4).await?; 29 | 30 | Ok(Secret { 31 | id: secret_row.0, 32 | name: secret_row.1.clone(), 33 | data: secret_row.2.as_bytes().to_vec(), 34 | owner: secret_row.3, 35 | meta, 36 | }) 37 | } 38 | } 39 | 40 | #[async_trait] 41 | impl<'a, M: MetadataRepository + std::marker::Sync + std::marker::Send> SecretRepository 42 | for PostgresSecretRepository<'a, M> 43 | { 44 | #[instrument(skip(self))] 45 | async fn find(&self, target: i32) -> Result { 46 | let row: PostgresSecretRow = { 47 | // block is required because of connection release 48 | sqlx::query_as(QUERY_FIND_SECRET) 49 | .bind(target) 50 | .fetch_one(self.pool) 51 | .await 52 | .map_err(|err| { 53 | error!( 54 | error = err.to_string(), 55 | "performing select by id query on postgres", 56 | ); 57 | Error::Unknown 58 | })? 59 | }; 60 | 61 | if row.0 == 0 { 62 | return Err(Error::NotFound); 63 | } 64 | 65 | self.build(&row).await // another connection consumed here 66 | } 67 | 68 | #[instrument(skip(self))] 69 | async fn find_by_user_and_name(&self, user: i32, secret_name: &str) -> Result { 70 | let row: PostgresSecretRow = { 71 | // block is required because of connection release 72 | sqlx::query_as(QUERY_FIND_SECRET_BY_USER_AND_NAME) 73 | .bind(user) 74 | .bind(secret_name) 75 | .fetch_one(self.pool) 76 | .await 77 | .map_err(|err| { 78 | if matches!(err, SqlError::RowNotFound) { 79 | return Error::NotFound; 80 | } 81 | 82 | error!( 83 | error = err.to_string(), 84 | "performing select by user and name query on postgres", 85 | ); 86 | Error::Unknown 87 | })? 88 | }; 89 | 90 | if row.0 == 0 { 91 | return Err(Error::NotFound); 92 | } 93 | 94 | self.build(&row).await // another connection consumed here 95 | } 96 | 97 | #[instrument(skip(self))] 98 | async fn create(&self, secret: &mut Secret) -> Result<()> { 99 | self.metadata_repo.create(&mut secret.meta).await?; 100 | 101 | let row: (i32,) = sqlx::query_as(QUERY_INSERT_SECRET) 102 | .bind(&secret.name) 103 | .bind(&secret.data) 104 | .bind(secret.owner) 105 | .bind(secret.meta.get_id()) 106 | .fetch_one(self.pool) 107 | .await 108 | .map_err(|err| { 109 | error!( 110 | error = err.to_string(), 111 | "performing insert query on postgres", 112 | ); 113 | Error::Unknown 114 | })?; 115 | 116 | secret.id = row.0; 117 | Ok(()) 118 | } 119 | 120 | #[instrument(skip(self))] 121 | async fn save(&self, secret: &Secret) -> Result<()> { 122 | sqlx::query(QUERY_UPDATE_SECRET) 123 | .bind(secret.id) 124 | .bind(&secret.name) 125 | .bind(&secret.data) 126 | .bind(secret.owner) 127 | .bind(secret.meta.get_id()) 128 | .fetch_one(self.pool) 129 | .await 130 | .map_err(|err| { 131 | error!( 132 | error = err.to_string(), 133 | "performing update query on postgres", 134 | ); 135 | Error::Unknown 136 | })?; 137 | 138 | Ok(()) 139 | } 140 | 141 | #[instrument(skip(self))] 142 | async fn delete(&self, secret: &Secret) -> Result<()> { 143 | { 144 | // block is required because of connection release 145 | sqlx::query(QUERY_DELETE_SECRET) 146 | .bind(secret.id) 147 | .fetch_one(self.pool) 148 | .await 149 | .map_err(|err| { 150 | error!( 151 | error = err.to_string(), 152 | "performing delete query on postgres", 153 | ); 154 | Error::Unknown 155 | })?; 156 | } 157 | 158 | self.metadata_repo.delete(&secret.meta).await?; // another connection consumed here 159 | Ok(()) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/session/application.rs: -------------------------------------------------------------------------------- 1 | use crate::crypto; 2 | use crate::regex; 3 | use crate::result::{Error, Result}; 4 | use crate::secret::application::SecretRepository; 5 | use crate::token::application::GenerateOptions; 6 | use crate::token::application::TokenApplication; 7 | use crate::token::application::TokenRepository; 8 | use crate::token::application::VerifyOptions; 9 | use crate::token::domain::TokenKind; 10 | use crate::user::application::UserRepository; 11 | use std::sync::Arc; 12 | 13 | pub struct SessionApplication<'a, T: TokenRepository, U: UserRepository, E: SecretRepository> { 14 | pub user_repo: Arc, 15 | pub secret_repo: Arc, 16 | pub token_app: Arc>, 17 | pub totp_secret_name: &'a str, 18 | pub pwd_sufix: &'a str, 19 | } 20 | 21 | impl<'a, T: TokenRepository, U: UserRepository, E: SecretRepository> 22 | SessionApplication<'a, T, U, E> 23 | { 24 | #[instrument(skip(self))] 25 | pub async fn login(&self, ident: &str, pwd: &str, totp: &str) -> Result { 26 | let user = { 27 | if regex::match_regex(regex::EMAIL, ident).is_ok() { 28 | self.user_repo.find_by_email(ident).await 29 | } else { 30 | self.user_repo.find_by_name(ident).await 31 | } 32 | } 33 | .map_err(|_| Error::WrongCredentials)?; 34 | 35 | let pwd = crypto::obfuscate(pwd, self.pwd_sufix); 36 | if !user.match_password(&pwd) { 37 | return Err(Error::WrongCredentials); 38 | } 39 | 40 | // if, and only if, the user has activated the totp 41 | if let Ok(secret) = self 42 | .secret_repo 43 | .find_by_user_and_name(user.get_id(), self.totp_secret_name) 44 | .await 45 | { 46 | if !secret.is_deleted() { 47 | let data = secret.get_data(); 48 | if !crypto::verify_totp(data, totp)? { 49 | return Err(Error::Unauthorized); 50 | } 51 | } 52 | } 53 | 54 | self.token_app 55 | .generate( 56 | TokenKind::Session, 57 | &user.get_id().to_string(), 58 | None, 59 | GenerateOptions::default(), 60 | ) 61 | .await 62 | .map(|token| token.signature().to_string()) 63 | } 64 | 65 | #[instrument(skip(self))] 66 | pub async fn logout(&self, token: &str) -> Result<()> { 67 | logout_strategy::(&self.token_app, token).await 68 | } 69 | } 70 | 71 | pub(super) async fn logout_strategy<'b, R: TokenRepository>( 72 | token_app: &TokenApplication<'b, R>, 73 | token: &str, 74 | ) -> Result<()> { 75 | let token = token_app.decode(token).await?; 76 | 77 | token_app 78 | .verify(&token, VerifyOptions::new(TokenKind::Session)) 79 | .await?; 80 | 81 | token_app.revoke(&token).await 82 | } 83 | 84 | #[cfg(test)] 85 | pub mod tests { 86 | use super::{SessionApplication, TokenRepository}; 87 | use crate::secret::application::tests::SecretRepositoryMock; 88 | use crate::secret::domain::tests::TEST_DEFAULT_SECRET_DATA; 89 | use crate::secret::domain::Secret; 90 | use crate::token::application::tests::{ 91 | new_token, new_token_application, PRIVATE_KEY, PUBLIC_KEY, 92 | }; 93 | use crate::token::domain::{Token, TokenKind}; 94 | use crate::user::domain::tests::TEST_DEFAULT_PWD_SUFIX; 95 | use crate::user::{ 96 | application::tests::{UserRepositoryMock, TEST_FIND_BY_EMAIL_ID, TEST_FIND_BY_NAME_ID}, 97 | domain::tests::{ 98 | TEST_DEFAULT_USER_EMAIL, TEST_DEFAULT_USER_NAME, TEST_DEFAULT_USER_PASSWORD, 99 | }, 100 | domain::User, 101 | }; 102 | use crate::{ 103 | crypto, 104 | result::{Error, Result}, 105 | }; 106 | use async_trait::async_trait; 107 | use std::sync::Arc; 108 | 109 | type MockFnFind = Option Result>; 110 | type MockFnSave = Option< 111 | fn(this: &TokenRepositoryMock, key: &str, token: &str, expire: Option) -> Result<()>, 112 | >; 113 | type MockFnDelete = Option Result<()>>; 114 | 115 | #[derive(Default, Clone)] 116 | pub struct TokenRepositoryMock { 117 | pub fn_find: MockFnFind, 118 | pub fn_save: MockFnSave, 119 | pub fn_delete: MockFnDelete, 120 | pub token: String, 121 | } 122 | 123 | #[async_trait] 124 | impl TokenRepository for TokenRepositoryMock { 125 | async fn find(&self, key: &str) -> Result { 126 | if let Some(fn_find) = self.fn_find { 127 | return fn_find(self, key); 128 | } 129 | 130 | Ok(self.token.clone()) 131 | } 132 | 133 | async fn save(&self, key: &str, token: &str, expire: Option) -> Result<()> { 134 | if let Some(fn_save) = self.fn_save { 135 | return fn_save(self, key, token, expire); 136 | } 137 | 138 | Ok(()) 139 | } 140 | 141 | async fn delete(&self, key: &str) -> Result<()> { 142 | if let Some(fn_delete) = self.fn_delete { 143 | return fn_delete(self, key); 144 | } 145 | 146 | Ok(()) 147 | } 148 | } 149 | 150 | pub fn new_session_application<'a, T: TokenRepository + Default>( 151 | token_repo: Option, 152 | ) -> SessionApplication<'a, T, UserRepositoryMock, SecretRepositoryMock> { 153 | let user_repo = UserRepositoryMock::default(); 154 | let secret_repo = SecretRepositoryMock::default(); 155 | let token_app = new_token_application(token_repo); 156 | 157 | SessionApplication { 158 | user_repo: Arc::new(user_repo), 159 | secret_repo: Arc::new(secret_repo), 160 | token_app: Arc::new(token_app), 161 | totp_secret_name: ".dummy_totp_secret", 162 | pwd_sufix: TEST_DEFAULT_PWD_SUFIX, 163 | } 164 | } 165 | 166 | #[tokio::test] 167 | async fn login_by_email_should_not_fail() { 168 | let secret_repo = SecretRepositoryMock { 169 | fn_find_by_user_and_name: Some( 170 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 171 | Err(Error::NotFound) 172 | }, 173 | ), 174 | ..Default::default() 175 | }; 176 | 177 | let mut app = new_session_application::(None); 178 | app.secret_repo = Arc::new(secret_repo); 179 | 180 | let token = app 181 | .login(TEST_DEFAULT_USER_EMAIL, TEST_DEFAULT_USER_PASSWORD, "") 182 | .await 183 | .map_err(|err| { 184 | println!( 185 | "-\tlogin_by_email_should_not_fail has failed with error {}", 186 | err 187 | ) 188 | }) 189 | .unwrap(); 190 | let session: Token = crypto::decode_jwt(&PUBLIC_KEY, &token).unwrap(); 191 | 192 | assert_eq!(session.sub, TEST_FIND_BY_EMAIL_ID.to_string()); 193 | } 194 | 195 | #[tokio::test] 196 | async fn login_by_username_should_not_fail() { 197 | let secret_repo = SecretRepositoryMock { 198 | fn_find_by_user_and_name: Some( 199 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 200 | Err(Error::NotFound) 201 | }, 202 | ), 203 | ..Default::default() 204 | }; 205 | 206 | let mut app = new_session_application::(None); 207 | app.secret_repo = Arc::new(secret_repo); 208 | let token = app 209 | .login(TEST_DEFAULT_USER_NAME, TEST_DEFAULT_USER_PASSWORD, "") 210 | .await 211 | .map_err(|err| { 212 | println!( 213 | "-\tlogin_by_username_should_not_fail has failed with error {}", 214 | err 215 | ) 216 | }) 217 | .unwrap(); 218 | let session: Token = crypto::decode_jwt(&PUBLIC_KEY, &token).unwrap(); 219 | assert_eq!(session.sub, TEST_FIND_BY_NAME_ID.to_string()); 220 | } 221 | 222 | #[tokio::test] 223 | async fn login_with_totp_should_not_fail() { 224 | let app = new_session_application::(None); 225 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 226 | .unwrap() 227 | .generate(); 228 | let token = app 229 | .login(TEST_DEFAULT_USER_NAME, TEST_DEFAULT_USER_PASSWORD, &code) 230 | .await 231 | .map_err(|err| { 232 | println!( 233 | "-\tlogin_with_totp_should_not_fail has failed with error {}", 234 | err 235 | ) 236 | }) 237 | .unwrap(); 238 | let session: Token = crypto::decode_jwt(&PUBLIC_KEY, &token).unwrap(); 239 | assert_eq!(session.sub, TEST_FIND_BY_NAME_ID.to_string()); 240 | } 241 | 242 | #[tokio::test] 243 | async fn login_user_not_found_should_fail() { 244 | let user_repo = UserRepositoryMock { 245 | fn_find_by_email: Some(|_: &UserRepositoryMock, _: &str| -> Result { 246 | Err(Error::WrongCredentials) 247 | }), 248 | ..Default::default() 249 | }; 250 | 251 | let mut app = new_session_application::(None); 252 | app.user_repo = Arc::new(user_repo); 253 | 254 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 255 | .unwrap() 256 | .generate(); 257 | 258 | app.login(TEST_DEFAULT_USER_EMAIL, TEST_DEFAULT_USER_PASSWORD, &code) 259 | .await 260 | .map_err(|err| assert_eq!(err.to_string(), Error::WrongCredentials.to_string())) 261 | .unwrap_err(); 262 | } 263 | 264 | #[tokio::test] 265 | async fn login_wrong_password_should_fail() { 266 | let app = new_session_application::(None); 267 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 268 | .unwrap() 269 | .generate(); 270 | app.login(TEST_DEFAULT_USER_NAME, "fake_password", &code) 271 | .await 272 | .map_err(|err| assert_eq!(err.to_string(), Error::WrongCredentials.to_string())) 273 | .unwrap_err(); 274 | } 275 | 276 | #[tokio::test] 277 | async fn login_wrong_totp_should_fail() { 278 | let app = new_session_application::(None); 279 | 280 | app.login( 281 | TEST_DEFAULT_USER_NAME, 282 | TEST_DEFAULT_USER_PASSWORD, 283 | "fake_totp", 284 | ) 285 | .await 286 | .map_err(|err| assert_eq!(err.to_string(), Error::Unauthorized.to_string())) 287 | .unwrap_err(); 288 | } 289 | 290 | #[tokio::test] 291 | async fn logout_should_not_fail() { 292 | let token = crypto::sign_jwt(&PRIVATE_KEY, new_token(TokenKind::Session)).unwrap(); 293 | let token_repo = TokenRepositoryMock { 294 | token: token.clone(), 295 | ..Default::default() 296 | }; 297 | 298 | let app = new_session_application::(Some(token_repo)); 299 | app.logout(&token) 300 | .await 301 | .map_err(|err| println!("-\tlogout_should_not_fail has failed with error {}", err)) 302 | .unwrap(); 303 | } 304 | 305 | #[tokio::test] 306 | async fn logout_verification_token_kind_should_fail() { 307 | let token = new_token(TokenKind::Verification); 308 | let token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 309 | let token_repo = TokenRepositoryMock { 310 | token: token.clone(), 311 | ..Default::default() 312 | }; 313 | 314 | let app = new_session_application::(Some(token_repo)); 315 | app.logout(&token) 316 | .await 317 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 318 | .unwrap_err(); 319 | } 320 | 321 | #[tokio::test] 322 | async fn logout_reset_token_kind_should_fail() { 323 | let token = new_token(TokenKind::Reset); 324 | let token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 325 | let token_repo = TokenRepositoryMock { 326 | token: token.clone(), 327 | ..Default::default() 328 | }; 329 | 330 | let app = new_session_application::(Some(token_repo)); 331 | app.logout(&token) 332 | .await 333 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 334 | .unwrap_err(); 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /src/session/grpc.rs: -------------------------------------------------------------------------------- 1 | use super::application::SessionApplication; 2 | use crate::base64::B64_CUSTOM_ENGINE; 3 | use crate::secret::application::SecretRepository; 4 | use crate::token::application::TokenRepository; 5 | use crate::user::application::UserRepository; 6 | use crate::{grpc, result::Error}; 7 | use base64::Engine; 8 | use tonic::metadata::errors::InvalidMetadataValue; 9 | use tonic::{Request, Response, Status}; 10 | 11 | // Import the generated rust code into module 12 | mod proto { 13 | tonic::include_proto!("session"); 14 | } 15 | 16 | // Proto generated server traits 17 | use proto::session_server::Session; 18 | pub use proto::session_server::SessionServer; 19 | 20 | // Proto message structs 21 | use proto::{Empty, LoginRequest}; 22 | 23 | pub struct SessionGrpcService< 24 | T: TokenRepository + Sync + Send, 25 | U: UserRepository + Sync + Send, 26 | E: SecretRepository + Sync + Send, 27 | > { 28 | pub session_app: SessionApplication<'static, T, U, E>, 29 | pub jwt_header: &'static str, 30 | } 31 | 32 | #[tonic::async_trait] 33 | impl< 34 | T: 'static + TokenRepository + Sync + Send, 35 | U: 'static + UserRepository + Sync + Send, 36 | E: 'static + SecretRepository + Sync + Send, 37 | > Session for SessionGrpcService 38 | { 39 | #[instrument(skip(self))] 40 | async fn login(&self, request: Request) -> Result, Status> { 41 | let msg_ref = request.into_inner(); 42 | let token = self 43 | .session_app 44 | .login(&msg_ref.ident, &msg_ref.pwd, &msg_ref.totp) 45 | .await 46 | .map(|token| B64_CUSTOM_ENGINE.encode(token)) 47 | .map_err(|err| Status::aborted(err.to_string()))?; 48 | 49 | let mut res = Response::new(Empty {}); 50 | let token = token.parse().map_err(|err: InvalidMetadataValue| { 51 | error!(error = err.to_string(), "parsing token to header"); 52 | Into::::into(Error::Unknown) 53 | })?; 54 | 55 | res.metadata_mut().append(self.jwt_header, token); 56 | Ok(res) 57 | } 58 | 59 | #[instrument(skip(self))] 60 | async fn logout(&self, request: Request) -> Result, Status> { 61 | let token = grpc::get_encoded_header(&request, self.jwt_header)?; 62 | if let Err(err) = self.session_app.logout(&token).await { 63 | return Err(Status::aborted(err.to_string())); 64 | } 65 | 66 | Ok(Response::new(Empty {})) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/session/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod application; 2 | #[cfg(all(feature = "grpc", feature = "postgres"))] 3 | pub mod grpc; 4 | #[cfg(all(feature = "rest", feature = "postgres"))] 5 | pub mod rest; 6 | -------------------------------------------------------------------------------- /src/session/rest.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | http, 3 | token::application::{TokenApplication, TokenRepository}, 4 | token::{application::VerifyOptions, domain::TokenKind}, 5 | }; 6 | use actix_web::{web, HttpRequest, HttpResponse, Responder}; 7 | use std::sync::Arc; 8 | 9 | use super::application; 10 | 11 | pub struct SessionRestService { 12 | pub token_app: TokenApplication<'static, T>, 13 | pub jwt_header: &'static str, 14 | } 15 | 16 | impl SessionRestService { 17 | pub fn router(&self) -> impl Fn(&mut web::ServiceConfig) { 18 | |cfg: &mut web::ServiceConfig| { 19 | cfg.service(web::resource("/session").route(web::get().to(Self::get_session))); 20 | cfg.service(web::resource("/session").route(web::delete().to(Self::delete_session))); 21 | } 22 | } 23 | 24 | #[instrument(skip(app_data))] 25 | async fn get_session( 26 | app_data: web::Data>>, 27 | req: HttpRequest, 28 | ) -> impl Responder { 29 | match async move { 30 | let token = http::get_encoded_header(req, app_data.jwt_header)?; 31 | let token = app_data.token_app.decode(&token).await?; 32 | 33 | app_data 34 | .token_app 35 | .verify(&token, VerifyOptions::new(TokenKind::Session)) 36 | .await 37 | .map(|_| token) 38 | } 39 | .await 40 | { 41 | Ok(token) => HttpResponse::Accepted().json(token), 42 | Err(err) => HttpResponse::from(err), 43 | } 44 | } 45 | 46 | #[instrument(skip(app_data))] 47 | async fn delete_session( 48 | app_data: web::Data>>, 49 | req: HttpRequest, 50 | ) -> impl Responder { 51 | match async move { 52 | let token = http::get_encoded_header(req, app_data.jwt_header)?; 53 | application::logout_strategy::(&app_data.token_app, &token).await 54 | } 55 | .await 56 | { 57 | Ok(_) => HttpResponse::Ok().finish(), 58 | Err(err) => HttpResponse::from(err), 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/smtp.rs: -------------------------------------------------------------------------------- 1 | //! Smtp implementation for sending of predefined email templates. 2 | 3 | use crate::base64::B64_CUSTOM_ENGINE; 4 | use crate::result::{Error, Result, StdResult}; 5 | use crate::user::application as user_app; 6 | use base64::Engine; 7 | use lettre::address::AddressError; 8 | use lettre::message::{Mailbox, SinglePart}; 9 | use lettre::transport::smtp::authentication::Credentials; 10 | use lettre::transport::smtp::client::Tls; 11 | use lettre::{Message, SmtpTransport, Transport}; 12 | use tera::{Context, Tera}; 13 | 14 | const EMAIL_VERIFICATION_SUBJECT: &str = "Email verification"; 15 | const EMAIL_VERIFICATION_TEMPLATE: &str = "verification_email.html"; 16 | const EMAIL_RESET_SUBJECT: &str = "Reset password"; 17 | const EMAIL_RESET_TEMPLATE: &str = "reset_email.html"; 18 | 19 | /// Smtp represents an email sender 20 | pub struct Smtp<'a> { 21 | pub issuer: &'a str, 22 | pub origin: Mailbox, 23 | pub verification_subject: &'a str, 24 | pub verification_template: &'a str, 25 | pub reset_subject: &'a str, 26 | pub reset_template: &'a str, 27 | mailer: SmtpTransport, 28 | tera: Tera, 29 | } 30 | 31 | impl<'a> Smtp<'a> { 32 | pub fn new( 33 | origin: &str, 34 | templates_path: &str, 35 | smtp_transport: &str, 36 | smtp_credentials: Option<(String, String)>, 37 | ) -> StdResult { 38 | let origin = origin.parse()?; 39 | let tera = Tera::new(templates_path)?; 40 | 41 | let transport_attrs: Vec<&str> = smtp_transport.split(':').collect(); 42 | if transport_attrs.is_empty() || transport_attrs[0].is_empty() { 43 | error!("smtp transport is not valid"); 44 | return Err(Error::Unknown.to_string().into()); 45 | } 46 | 47 | let mut mailer = SmtpTransport::relay(transport_attrs[0])?; 48 | 49 | if transport_attrs.len() > 1 && !transport_attrs[1].is_empty() { 50 | mailer = mailer.port(transport_attrs[1].parse().unwrap()); 51 | } 52 | 53 | if let Some(credentials) = smtp_credentials { 54 | let creds = Credentials::new(credentials.0, credentials.1); 55 | mailer = mailer.credentials(creds); 56 | } else { 57 | warn!("transport layer security for smtp disabled"); 58 | mailer = mailer.tls(Tls::None); 59 | } 60 | 61 | Ok(Smtp { 62 | issuer: "", 63 | origin, 64 | mailer: mailer.build(), 65 | tera, 66 | verification_subject: EMAIL_VERIFICATION_SUBJECT, 67 | verification_template: EMAIL_VERIFICATION_TEMPLATE, 68 | reset_subject: EMAIL_RESET_SUBJECT, 69 | reset_template: EMAIL_RESET_TEMPLATE, 70 | }) 71 | } 72 | 73 | pub fn with_issuer(mut self, issuer: &'a str) -> Self { 74 | self.issuer = issuer; 75 | self 76 | } 77 | 78 | #[instrument(skip(self))] 79 | fn send_email(&self, to: &str, subject: &str, body: String) -> Result<()> { 80 | let formated_subject = if !self.issuer.is_empty() { 81 | format!("[{}] {}", self.issuer, subject) 82 | } else { 83 | subject.to_string() 84 | }; 85 | 86 | let to = to.parse().map_err(|err: AddressError| { 87 | error!( 88 | to, 89 | from = self.origin.to_string(), 90 | error = err.to_string(), 91 | "parsing verification email destination" 92 | ); 93 | Error::Unknown 94 | })?; 95 | 96 | let email = Message::builder() 97 | .from(self.origin.clone()) 98 | .to(to) 99 | .subject(formated_subject) 100 | .singlepart(SinglePart::html(body)) 101 | .map_err(|err| { 102 | error!(error = err.to_string(), "building email"); 103 | Error::Unknown 104 | })?; 105 | 106 | self.mailer.send(&email).map_err(|err| { 107 | error!(error = err.to_string(), "sending email"); 108 | Error::Unknown 109 | })?; 110 | 111 | Ok(()) 112 | } 113 | } 114 | 115 | impl<'a> user_app::Mailer for Smtp<'a> { 116 | #[instrument(skip(self))] 117 | fn send_verification_signup_email(&self, email: &str, token: &str) -> Result<()> { 118 | let mut context = Context::new(); 119 | context.insert("name", email.split('@').collect::>()[0]); 120 | context.insert("token", &B64_CUSTOM_ENGINE.encode(token)); 121 | 122 | let body = self 123 | .tera 124 | .render(self.verification_template, &context) 125 | .map_err(|err| { 126 | error!( 127 | error = err.to_string(), 128 | "rendering verification signup email template", 129 | ); 130 | Error::Unknown 131 | })?; 132 | 133 | self.send_email(email, self.verification_subject, body) 134 | } 135 | 136 | #[instrument(skip(self))] 137 | fn send_verification_reset_email(&self, email: &str, token: &str) -> Result<()> { 138 | let mut context = Context::new(); 139 | context.insert("name", email.split('@').collect::>()[0]); 140 | context.insert("token", &B64_CUSTOM_ENGINE.encode(token)); 141 | 142 | let body = self 143 | .tera 144 | .render(self.reset_template, &context) 145 | .map_err(|err| { 146 | error!( 147 | error = err.to_string(), 148 | "rendering verification reset email template", 149 | ); 150 | Error::Unknown 151 | })?; 152 | 153 | self.send_email(email, self.reset_subject, body) 154 | } 155 | } 156 | 157 | #[cfg(test)] 158 | pub mod tests { 159 | use crate::result::{Error, Result}; 160 | use crate::user::application::Mailer; 161 | 162 | #[derive(Default)] 163 | pub struct MailerMock { 164 | pub force_fail: bool, 165 | } 166 | 167 | impl Mailer for MailerMock { 168 | fn send_verification_signup_email(&self, _: &str, _: &str) -> Result<()> { 169 | if self.force_fail { 170 | return Err(Error::Unknown); 171 | } 172 | 173 | Ok(()) 174 | } 175 | 176 | fn send_verification_reset_email(&self, _: &str, _: &str) -> Result<()> { 177 | if self.force_fail { 178 | return Err(Error::Unknown); 179 | } 180 | 181 | Ok(()) 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/time.rs: -------------------------------------------------------------------------------- 1 | //! Datetime related utilities. 2 | 3 | use chrono::prelude::{DateTime, Utc}; 4 | use std::time::SystemTime; 5 | 6 | /// Given a system time returns its corresponding unix timestamp 7 | pub fn unix_timestamp(current: SystemTime) -> usize { 8 | let utc: DateTime = current.into(); 9 | utc.timestamp() as usize 10 | // formats like "2001-07-08T00:34:60.026490+09:30" 11 | // see: https://stackoverflow.com/questions/64146345/how-do-i-convert-a-systemtime-to-iso-8601-in-rust 12 | } 13 | -------------------------------------------------------------------------------- /src/token/application.rs: -------------------------------------------------------------------------------- 1 | use super::domain::SignedToken; 2 | use super::domain::{Token, TokenDefinition, TokenKind}; 3 | use crate::crypto; 4 | use crate::result::{Error, Result}; 5 | use async_trait::async_trait; 6 | use std::sync::Arc; 7 | use std::time::Duration; 8 | 9 | #[async_trait] 10 | pub trait TokenRepository { 11 | async fn find(&self, key: &str) -> Result; 12 | async fn save(&self, key: &str, token: &str, expire: Option) -> Result<()>; 13 | async fn delete(&self, key: &str) -> Result<()>; 14 | } 15 | 16 | pub struct TokenApplication<'a, T: TokenRepository> { 17 | pub token_repo: Arc, 18 | pub timeout: Duration, 19 | pub token_issuer: &'a str, 20 | pub private_key: &'a [u8], 21 | pub public_key: &'a [u8], 22 | } 23 | 24 | #[derive(Debug, Clone)] 25 | pub struct GenerateOptions { 26 | pub store: bool, 27 | } 28 | 29 | impl Default for GenerateOptions { 30 | fn default() -> Self { 31 | Self { store: true } 32 | } 33 | } 34 | 35 | #[derive(Debug, Clone)] 36 | pub struct VerifyOptions { 37 | pub must_exists: bool, 38 | pub kind: Option, 39 | } 40 | 41 | impl Default for VerifyOptions { 42 | fn default() -> Self { 43 | Self { 44 | must_exists: true, 45 | kind: None, 46 | } 47 | } 48 | } 49 | 50 | impl VerifyOptions { 51 | pub fn new(kind: TokenKind) -> Self { 52 | VerifyOptions { 53 | kind: Some(kind), 54 | ..Default::default() 55 | } 56 | } 57 | } 58 | 59 | impl<'a, T: TokenRepository> TokenApplication<'a, T> { 60 | #[instrument(skip(self))] 61 | pub async fn generate( 62 | &self, 63 | kind: TokenKind, 64 | sub: &str, 65 | secret: Option<&str>, 66 | options: GenerateOptions, 67 | ) -> Result { 68 | let token = Token::new(self.token_issuer, sub, self.timeout, kind, secret); 69 | let signed = crypto::sign_jwt(self.private_key, &token)?; 70 | 71 | if options.store { 72 | self.token_repo 73 | .save(&token.get_id(), &signed, Some(self.timeout.as_secs())) 74 | .await?; 75 | } 76 | 77 | Ok(SignedToken { 78 | id: token.get_id(), 79 | signature: signed, 80 | }) 81 | } 82 | 83 | #[instrument(skip(self))] 84 | pub async fn decode(&self, token: &str) -> Result { 85 | crypto::decode_jwt(self.public_key, token) 86 | } 87 | 88 | #[instrument(skip(self))] 89 | pub async fn retrieve(&self, key: &str) -> Result { 90 | let token = self.token_repo.find(key).await?; 91 | let claims = self.decode(&token).await?; 92 | Ok(claims) 93 | } 94 | 95 | #[instrument(skip(self))] 96 | pub async fn verify(&self, token: &Token, options: VerifyOptions) -> Result<()> { 97 | if let Some(kind) = options.kind { 98 | if *token.get_kind() != kind { 99 | warn!( 100 | token_id = token.get_id(), 101 | token_kind = token.get_kind().to_string(), 102 | expected_kind = kind.to_string(), 103 | "checking token's kind", 104 | ); 105 | return Err(Error::InvalidToken); 106 | } 107 | } 108 | 109 | if options.must_exists { 110 | let key = token.get_id(); 111 | let present_data = self.token_repo.find(&key).await.map_err(|err| { 112 | warn!( 113 | error = err.to_string(), 114 | token_id = key, 115 | "finding token by id", 116 | ); 117 | Error::InvalidToken 118 | })?; 119 | 120 | let present_token = self.decode(&present_data).await?; 121 | if token != &present_token { 122 | error!(token_id = key, "token does not match"); 123 | return Err(Error::InvalidToken); 124 | } 125 | } 126 | 127 | Ok(()) 128 | } 129 | 130 | #[instrument(skip(self))] 131 | pub async fn revoke(&self, token: &Token) -> Result<()> { 132 | let key = token.get_id(); 133 | self.token_repo.find(&key).await.map_err(|err| { 134 | warn!( 135 | error = err.to_string(), 136 | token_id = key, 137 | "finding token by id", 138 | ); 139 | Error::InvalidToken 140 | })?; 141 | 142 | self.token_repo.delete(&key).await 143 | } 144 | } 145 | 146 | #[cfg(test)] 147 | pub mod tests { 148 | use super::{TokenApplication, TokenRepository}; 149 | use crate::time; 150 | use crate::token::application::VerifyOptions; 151 | use crate::token::domain::{Token, TokenKind}; 152 | use crate::{ 153 | crypto, 154 | result::{Error, Result}, 155 | }; 156 | use async_trait::async_trait; 157 | use base64::{engine::general_purpose, Engine as _}; 158 | use lazy_static::lazy_static; 159 | use std::sync::Arc; 160 | use std::time::{Duration, SystemTime}; 161 | 162 | lazy_static! { 163 | pub static ref PRIVATE_KEY: Vec = general_purpose::STANDARD.decode( 164 | b"LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR0hBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJHMHdhd0lCQVFRZy9JMGJTbVZxL1BBN2FhRHgKN1FFSGdoTGxCVS9NcWFWMUJab3ZhM2Y5aHJxaFJBTkNBQVJXZVcwd3MydmlnWi96SzRXcGk3Rm1mK0VPb3FybQpmUlIrZjF2azZ5dnBGd0gzZllkMlllNXl4b3ZsaTROK1ZNNlRXVFErTmVFc2ZmTWY2TkFBMloxbQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tCg==" 165 | ).unwrap(); 166 | pub static ref PUBLIC_KEY: Vec = general_purpose::STANDARD.decode( 167 | b"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFVm5sdE1MTnI0b0dmOHl1RnFZdXhabi9oRHFLcQo1bjBVZm45YjVPc3I2UmNCOTMySGRtSHVjc2FMNVl1RGZsVE9rMWswUGpYaExIM3pIK2pRQU5tZFpnPT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==" 168 | ).unwrap(); 169 | } 170 | 171 | type MockFnFind = Option Result>; 172 | type MockFnSave = Option< 173 | fn(this: &TokenRepositoryMock, key: &str, token: &str, expire: Option) -> Result<()>, 174 | >; 175 | type MockFnDelete = Option Result<()>>; 176 | 177 | pub const TEST_DEFAULT_TOKEN_TIMEOUT: u64 = 60; 178 | 179 | pub fn new_token(kind: TokenKind) -> Token { 180 | const ISS: &str = "test"; 181 | const SUB: i32 = 999; 182 | 183 | let timeout = Duration::from_secs(TEST_DEFAULT_TOKEN_TIMEOUT); 184 | Token::new(ISS, &SUB.to_string(), timeout, kind, None) 185 | } 186 | 187 | #[derive(Default, Clone)] 188 | pub struct TokenRepositoryMock { 189 | pub fn_find: MockFnFind, 190 | pub fn_save: MockFnSave, 191 | pub fn_delete: MockFnDelete, 192 | pub token: String, 193 | } 194 | 195 | #[async_trait] 196 | impl TokenRepository for TokenRepositoryMock { 197 | async fn find(&self, key: &str) -> Result { 198 | if let Some(fn_find) = self.fn_find { 199 | return fn_find(self, key); 200 | } 201 | 202 | Ok(self.token.clone()) 203 | } 204 | 205 | async fn save(&self, key: &str, token: &str, expire: Option) -> Result<()> { 206 | if let Some(fn_save) = self.fn_save { 207 | return fn_save(self, key, token, expire); 208 | } 209 | 210 | Ok(()) 211 | } 212 | 213 | async fn delete(&self, key: &str) -> Result<()> { 214 | if let Some(fn_delete) = self.fn_delete { 215 | return fn_delete(self, key); 216 | } 217 | 218 | Ok(()) 219 | } 220 | } 221 | 222 | pub fn new_token_application<'a, T: TokenRepository + Default>( 223 | token_repo: Option, 224 | ) -> TokenApplication<'a, T> { 225 | TokenApplication { 226 | token_repo: Arc::new(token_repo.unwrap_or_default()), 227 | timeout: Duration::from_secs(999), 228 | token_issuer: "dummy", 229 | private_key: &PRIVATE_KEY, 230 | public_key: &PUBLIC_KEY, 231 | } 232 | } 233 | 234 | #[tokio::test] 235 | async fn verify_token_should_not_fail() { 236 | let token = crypto::sign_jwt(&PRIVATE_KEY, new_token(TokenKind::Session)).unwrap(); 237 | let token_repo = TokenRepositoryMock { 238 | token: token.clone(), 239 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 240 | Ok(this.token.clone()) 241 | }), 242 | ..Default::default() 243 | }; 244 | 245 | let app = new_token_application(Some(token_repo)); 246 | let claims = app.decode(&token).await.unwrap(); 247 | app.verify(&claims, VerifyOptions::new(TokenKind::Session)) 248 | .await 249 | .unwrap(); 250 | } 251 | 252 | #[tokio::test] 253 | async fn decode_token_expired_should_fail() { 254 | let mut claim = new_token(TokenKind::Session); 255 | claim.exp = time::unix_timestamp(SystemTime::now() - Duration::from_secs(61)); 256 | 257 | let token = crypto::sign_jwt(&PRIVATE_KEY, claim).unwrap(); 258 | let app = new_token_application::(None); 259 | 260 | app.decode(&token) 261 | .await 262 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 263 | .unwrap_err(); 264 | } 265 | 266 | #[tokio::test] 267 | async fn decode_token_invalid_should_fail() { 268 | let token = crypto::sign_jwt(&PRIVATE_KEY, new_token(TokenKind::Session)) 269 | .unwrap() 270 | .replace('A', "a"); 271 | let token_repo = TokenRepositoryMock { 272 | token: token.clone(), 273 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 274 | Ok(this.token.clone()) 275 | }), 276 | ..Default::default() 277 | }; 278 | 279 | let app = new_token_application(Some(token_repo)); 280 | app.decode(&token) 281 | .await 282 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 283 | .unwrap_err(); 284 | } 285 | 286 | #[tokio::test] 287 | async fn verify_token_wrong_kind_should_fail() { 288 | let token = crypto::sign_jwt(&PRIVATE_KEY, new_token(TokenKind::Session)).unwrap(); 289 | let token_repo = TokenRepositoryMock { 290 | token: token.clone(), 291 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 292 | Ok(this.token.clone()) 293 | }), 294 | ..Default::default() 295 | }; 296 | 297 | let app = new_token_application(Some(token_repo)); 298 | let claims = app.decode(&token).await.unwrap(); 299 | app.verify(&claims, VerifyOptions::new(TokenKind::Verification)) 300 | .await 301 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 302 | .unwrap_err(); 303 | } 304 | 305 | #[tokio::test] 306 | async fn verify_token_not_present_should_fail() { 307 | let token = crypto::sign_jwt(&PRIVATE_KEY, new_token(TokenKind::Session)).unwrap(); 308 | let token_repo = TokenRepositoryMock { 309 | token: token.clone(), 310 | fn_find: Some(|_: &TokenRepositoryMock, _: &str| -> Result { 311 | Err(Error::NotFound) 312 | }), 313 | ..Default::default() 314 | }; 315 | 316 | let app = new_token_application(Some(token_repo)); 317 | let claims = app.decode(&token).await.unwrap(); 318 | app.verify(&claims, VerifyOptions::new(TokenKind::Verification)) 319 | .await 320 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 321 | .unwrap_err(); 322 | } 323 | 324 | #[tokio::test] 325 | async fn verify_token_mismatch_should_fail() { 326 | let token = crypto::sign_jwt(&PRIVATE_KEY, new_token(TokenKind::Session)).unwrap(); 327 | let token_repo = TokenRepositoryMock { 328 | token: token.clone(), 329 | fn_find: Some(|_: &TokenRepositoryMock, _: &str| -> Result { 330 | Ok("hello world".to_string()) 331 | }), 332 | ..Default::default() 333 | }; 334 | 335 | let app = new_token_application(Some(token_repo)); 336 | let claims = app.decode(&token).await.unwrap(); 337 | app.verify(&claims, VerifyOptions::new(TokenKind::Verification)) 338 | .await 339 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 340 | .unwrap_err(); 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/token/domain.rs: -------------------------------------------------------------------------------- 1 | use crate::time; 2 | use rand::Rng; 3 | use std::collections::hash_map::DefaultHasher; 4 | use std::hash::{Hash, Hasher}; 5 | use std::time::{Duration, SystemTime}; 6 | 7 | pub trait TokenDefinition { 8 | fn get_id(&self) -> String; 9 | fn get_secret(&self) -> Option<&str>; 10 | fn get_kind(&self) -> &TokenKind; 11 | } 12 | 13 | pub struct SignedToken { 14 | pub(super) id: String, 15 | pub(super) signature: String, 16 | } 17 | 18 | impl SignedToken { 19 | pub fn id(&self) -> &str { 20 | &self.id 21 | } 22 | 23 | pub fn signature(&self) -> &str { 24 | &self.signature 25 | } 26 | } 27 | 28 | #[derive(Serialize, Deserialize, Hash, PartialEq, Eq, Debug, Clone, strum_macros::Display)] 29 | pub enum TokenKind { 30 | Session = 0, 31 | Verification = 1, 32 | Reset = 2, 33 | } 34 | 35 | #[derive(Serialize, Deserialize, Hash, Debug, Clone, PartialEq)] 36 | pub struct Token { 37 | pub jti: String, // JWT ID 38 | pub exp: usize, // expiration time (as UTC timestamp) - required 39 | pub nbf: usize, // not before time (as UTC timestamp) - non required 40 | pub iat: SystemTime, // issued at: creation time 41 | pub iss: String, // issuer 42 | pub sub: String, // subject 43 | pub knd: TokenKind, // kind - required 44 | #[serde(skip_serializing_if = "Option::is_none")] 45 | #[serde(default = "Token::default_secret_value")] 46 | pub scr: Option, // secret data 47 | } 48 | 49 | impl Token { 50 | fn default_secret_value() -> Option { 51 | None 52 | } 53 | 54 | pub fn new( 55 | iss: &str, 56 | sub: &str, 57 | timeout: Duration, 58 | kind: TokenKind, 59 | secret: Option<&str>, 60 | ) -> Self { 61 | let mut token = Token { 62 | jti: rand::thread_rng().gen::().to_string(), // noise 63 | exp: time::unix_timestamp(SystemTime::now() + timeout), 64 | nbf: time::unix_timestamp(SystemTime::now()), 65 | iat: SystemTime::now(), 66 | iss: iss.to_string(), 67 | sub: sub.to_string(), 68 | knd: kind, 69 | scr: secret.map(ToString::to_string), 70 | }; 71 | 72 | let mut hasher = DefaultHasher::new(); 73 | token.hash(&mut hasher); 74 | token.jti = hasher.finish().to_string(); 75 | 76 | token 77 | } 78 | } 79 | 80 | impl TokenDefinition for Token { 81 | fn get_id(&self) -> String { 82 | format!("{:?}::{}", self.knd, self.jti) 83 | } 84 | 85 | fn get_kind(&self) -> &TokenKind { 86 | &self.knd 87 | } 88 | 89 | fn get_secret(&self) -> Option<&str> { 90 | self.scr.as_deref() 91 | } 92 | } 93 | 94 | #[cfg(test)] 95 | pub mod tests { 96 | use super::{Token, TokenKind}; 97 | use crate::time::unix_timestamp; 98 | use crate::{crypto, time}; 99 | use base64::{engine::general_purpose, Engine as _}; 100 | use std::time::{Duration, SystemTime}; 101 | 102 | pub const TEST_DEFAULT_TOKEN_TIMEOUT: u64 = 60; 103 | const JWT_SECRET: &[u8] = b"LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR0hBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJHMHdhd0lCQVFRZy9JMGJTbVZxL1BBN2FhRHgKN1FFSGdoTGxCVS9NcWFWMUJab3ZhM2Y5aHJxaFJBTkNBQVJXZVcwd3MydmlnWi96SzRXcGk3Rm1mK0VPb3FybQpmUlIrZjF2azZ5dnBGd0gzZllkMlllNXl4b3ZsaTROK1ZNNlRXVFErTmVFc2ZmTWY2TkFBMloxbQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tCg=="; 104 | const JWT_PUBLIC: &[u8] = b"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFVm5sdE1MTnI0b0dmOHl1RnFZdXhabi9oRHFLcQo1bjBVZm45YjVPc3I2UmNCOTMySGRtSHVjc2FMNVl1RGZsVE9rMWswUGpYaExIM3pIK2pRQU5tZFpnPT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="; 105 | 106 | #[test] 107 | fn token_new_should_not_fail() { 108 | const ISS: &str = "test"; 109 | const SUB: i32 = 999; 110 | 111 | let timeout = Duration::from_secs(TEST_DEFAULT_TOKEN_TIMEOUT); 112 | 113 | let before = SystemTime::now(); 114 | let claim = Token::new(ISS, &SUB.to_string(), timeout, TokenKind::Session, None); 115 | let after = SystemTime::now(); 116 | 117 | assert!(claim.iat >= before && claim.iat <= after); 118 | assert!(claim.exp >= unix_timestamp(before + timeout)); 119 | assert!(claim.exp <= unix_timestamp(after + timeout)); 120 | assert_eq!(claim.knd, TokenKind::Session); 121 | assert_eq!(ISS, claim.iss); 122 | assert_eq!(SUB.to_string(), claim.sub); 123 | } 124 | 125 | #[test] 126 | fn token_encode_should_not_fail() { 127 | const ISS: &str = "test"; 128 | const SUB: i32 = 999; 129 | let timeout = Duration::from_secs(TEST_DEFAULT_TOKEN_TIMEOUT); 130 | 131 | let before = SystemTime::now(); 132 | let claim = Token::new(ISS, &SUB.to_string(), timeout, TokenKind::Session, None); 133 | 134 | let after = SystemTime::now(); 135 | 136 | let secret = general_purpose::STANDARD.decode(JWT_SECRET).unwrap(); 137 | let token = crypto::sign_jwt(&secret, claim).unwrap(); 138 | 139 | let public = general_purpose::STANDARD.decode(JWT_PUBLIC).unwrap(); 140 | let claim = crypto::decode_jwt::(&public, &token).unwrap(); 141 | 142 | assert!(claim.iat >= before && claim.iat <= after); 143 | assert!(claim.exp >= unix_timestamp(before + timeout)); 144 | assert!(claim.exp <= unix_timestamp(after + timeout)); 145 | assert_eq!(ISS, claim.iss); 146 | assert_eq!(SUB.to_string(), claim.sub); 147 | } 148 | 149 | #[test] 150 | fn expired_token_verification_should_fail() { 151 | use crate::crypto; 152 | 153 | const ISS: &str = "test"; 154 | const SUB: i32 = 999; 155 | 156 | let timeout = Duration::from_secs(TEST_DEFAULT_TOKEN_TIMEOUT); 157 | let mut claim = Token::new(ISS, &SUB.to_string(), timeout, TokenKind::Session, None); 158 | claim.exp = time::unix_timestamp(SystemTime::now() - Duration::from_secs(61)); 159 | 160 | let secret = general_purpose::STANDARD.decode(JWT_SECRET).unwrap(); 161 | let token = crypto::sign_jwt(&secret, claim).unwrap(); 162 | let public = general_purpose::STANDARD.decode(JWT_PUBLIC).unwrap(); 163 | 164 | assert!(crypto::decode_jwt::(&public, &token).is_err()); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/token/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod application; 2 | pub mod domain; 3 | #[cfg(feature = "redis-cache")] 4 | pub mod repository; 5 | -------------------------------------------------------------------------------- /src/token/repository.rs: -------------------------------------------------------------------------------- 1 | use std::num::TryFromIntError; 2 | 3 | use super::application::TokenRepository; 4 | use crate::result::{Error, Result}; 5 | use async_trait::async_trait; 6 | use reool::AsyncCommands; 7 | use reool::*; 8 | 9 | pub struct RedisTokenRepository<'a> { 10 | pub pool: &'a RedisPool, 11 | } 12 | 13 | #[async_trait] 14 | impl<'a> TokenRepository for RedisTokenRepository<'a> { 15 | #[instrument(skip(self))] 16 | async fn find(&self, key: &str) -> Result { 17 | let mut conn = self.pool.check_out(PoolDefault).await.map_err(|err| { 18 | error!(error = err.to_string(), "pulling connection for redis",); 19 | Error::Unknown 20 | })?; 21 | 22 | let token: Vec = conn.get(key).await.map_err(|err| { 23 | error!(error = err.to_string(), "performing GET command on redis",); 24 | Error::Unknown 25 | })?; 26 | 27 | let token: String = String::from_utf8(token).map_err(|err| { 28 | error!(error = err.to_string(), "parsing token to string",); 29 | Error::Unknown 30 | })?; 31 | Ok(token) 32 | } 33 | 34 | #[instrument(skip(self))] 35 | async fn save(&self, key: &str, token: &str, expire: Option) -> Result<()> { 36 | let mut conn = self.pool.check_out(PoolDefault).await.map_err(|err| { 37 | error!(error = err.to_string(), "pulling connection for redis",); 38 | Error::Unknown 39 | })?; 40 | 41 | conn.set(key, token).await.map_err(|err| { 42 | error!(error = err.to_string(), "performing SET command on redis",); 43 | Error::Unknown 44 | })?; 45 | 46 | if let Some(expire) = expire { 47 | let expire = expire.try_into().map_err(|err: TryFromIntError| { 48 | error!(error = err.to_string(), "parsing expiration time to usize",); 49 | Error::Unknown 50 | })?; 51 | 52 | conn.expire(key, expire).await.map_err(|err| { 53 | error!( 54 | error = err.to_string(), 55 | "performing EXPIRE command on redis", 56 | ); 57 | Error::Unknown 58 | })?; 59 | } 60 | Ok(()) 61 | } 62 | 63 | #[instrument(skip(self))] 64 | async fn delete(&self, key: &str) -> Result<()> { 65 | let mut conn = self.pool.check_out(PoolDefault).await.map_err(|err| { 66 | error!(error = err.to_string(), "pulling connection for redis",); 67 | Error::Unknown 68 | })?; 69 | 70 | conn.del(key).await.map_err(|err| { 71 | error!( 72 | error = err.to_string(), 73 | "performing DELETE command on redis", 74 | ); 75 | Error::Unknown 76 | })?; 77 | 78 | Ok(()) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/user/application.rs: -------------------------------------------------------------------------------- 1 | use super::domain::User; 2 | use crate::crypto; 3 | use crate::result::{Error, Result}; 4 | use crate::secret::{application::SecretRepository, domain::Secret}; 5 | use crate::token::application::{GenerateOptions, VerifyOptions}; 6 | use crate::token::domain::TokenDefinition; 7 | use crate::token::{ 8 | application::{TokenApplication, TokenRepository}, 9 | domain::{Token, TokenKind}, 10 | }; 11 | use async_trait::async_trait; 12 | use chrono::Utc; 13 | use std::num::ParseIntError; 14 | use std::sync::Arc; 15 | 16 | #[async_trait] 17 | pub trait UserRepository { 18 | async fn find(&self, id: i32) -> Result; 19 | async fn find_by_email(&self, email: &str) -> Result; 20 | async fn find_by_name(&self, name: &str) -> Result; 21 | async fn create(&self, user: &mut User) -> Result<()>; 22 | async fn save(&self, user: &User) -> Result<()>; 23 | async fn delete(&self, user: &User) -> Result<()>; 24 | } 25 | 26 | #[async_trait] 27 | pub trait EventBus { 28 | async fn emit_user_created(&self, user: &User) -> Result<()>; 29 | } 30 | 31 | pub trait Mailer { 32 | fn send_verification_signup_email(&self, to: &str, token: &str) -> Result<()>; 33 | fn send_verification_reset_email(&self, to: &str, token: &str) -> Result<()>; 34 | } 35 | 36 | pub struct UserApplication< 37 | 'a, 38 | U: UserRepository, 39 | E: SecretRepository, 40 | T: TokenRepository, 41 | B: EventBus, 42 | M: Mailer, 43 | > { 44 | pub user_repo: Arc, 45 | pub secret_repo: Arc, 46 | pub token_app: Arc>, 47 | pub mailer: Arc, 48 | pub event_bus: Arc, 49 | pub totp_secret_len: usize, 50 | pub totp_secret_name: &'a str, 51 | pub pwd_sufix: &'a str, 52 | } 53 | 54 | impl<'a, U: UserRepository, E: SecretRepository, T: TokenRepository, B: EventBus, M: Mailer> 55 | UserApplication<'a, U, E, T, B, M> 56 | { 57 | #[instrument(skip(self))] 58 | pub async fn verify_signup_email(&self, email: &str, pwd: &str) -> Result<()> { 59 | if self.user_repo.find_by_email(email).await.is_ok() { 60 | // returns Ok to not provide information about users 61 | return Ok(()); 62 | } 63 | 64 | let pwd = crypto::obfuscate(pwd, self.pwd_sufix); 65 | User::new(email, &pwd)?; 66 | let token_to_keep = self 67 | .token_app 68 | .generate( 69 | TokenKind::Verification, 70 | email, 71 | Some(&pwd), 72 | GenerateOptions::default(), 73 | ) 74 | .await?; 75 | 76 | let token_to_send = self 77 | .token_app 78 | .generate( 79 | TokenKind::Verification, 80 | token_to_keep.id(), 81 | None, 82 | GenerateOptions { store: false }, 83 | ) 84 | .await?; 85 | 86 | self.mailer 87 | .send_verification_signup_email(email, token_to_send.signature())?; 88 | 89 | Ok(()) 90 | } 91 | 92 | #[instrument(skip(self))] 93 | pub async fn signup_with_token(&self, token: &str) -> Result { 94 | let claims: Token = self.token_app.decode(token).await?; 95 | self.token_app 96 | .verify( 97 | &claims, 98 | VerifyOptions { 99 | must_exists: false, 100 | kind: Some(TokenKind::Verification), 101 | }, 102 | ) 103 | .await?; 104 | 105 | let claims: Token = self.token_app.retrieve(&claims.sub).await?; 106 | self.token_app 107 | .verify(&claims, VerifyOptions::new(TokenKind::Verification)) 108 | .await?; 109 | 110 | let password = &claims.get_secret().ok_or(Error::InvalidToken)?; 111 | let token = self.signup(&claims.sub, password).await?; 112 | self.token_app.revoke(&claims).await?; 113 | 114 | Ok(token) 115 | } 116 | 117 | #[instrument(skip(self))] 118 | pub async fn signup(&self, email: &str, pwd: &str) -> Result { 119 | let mut user = User::new(email, pwd)?; 120 | self.user_repo.create(&mut user).await?; 121 | self.event_bus.emit_user_created(&user).await?; 122 | self.token_app 123 | .generate( 124 | TokenKind::Session, 125 | &user.get_id().to_string(), 126 | None, 127 | GenerateOptions::default(), 128 | ) 129 | .await 130 | .map(|token| token.signature().to_string()) 131 | } 132 | 133 | #[instrument(skip(self))] 134 | pub async fn delete_with_token(&self, token: &str, pwd: &str, totp: &str) -> Result<()> { 135 | let claims: Token = self.token_app.decode(token).await?; 136 | self.token_app 137 | .verify(&claims, VerifyOptions::new(TokenKind::Session)) 138 | .await?; 139 | 140 | let user_id = claims.sub.parse().map_err(|err: ParseIntError| { 141 | warn!(error = err.to_string(), "parsing str to i32"); 142 | Error::InvalidToken 143 | })?; 144 | 145 | self.delete(user_id, pwd, totp).await 146 | } 147 | 148 | #[instrument(skip(self))] 149 | pub async fn delete(&self, user_id: i32, pwd: &str, totp: &str) -> Result<()> { 150 | let user = self 151 | .user_repo 152 | .find(user_id) 153 | .await 154 | .map_err(|_| Error::WrongCredentials)?; 155 | 156 | let pwd = crypto::obfuscate(pwd, self.pwd_sufix); 157 | if !user.match_password(&pwd) { 158 | return Err(Error::WrongCredentials); 159 | } 160 | 161 | // if, and only if, the user has activated the totp 162 | let secret_lookup = self 163 | .secret_repo 164 | .find_by_user_and_name(user.id, self.totp_secret_name) 165 | .await 166 | .ok(); 167 | 168 | if let Some(secret) = secret_lookup { 169 | if !secret.is_deleted() { 170 | let data = secret.get_data(); 171 | if !crypto::verify_totp(data, totp)? { 172 | return Err(Error::Unauthorized); 173 | } 174 | self.secret_repo.delete(&secret).await?; 175 | } 176 | } 177 | 178 | self.user_repo.delete(&user).await?; 179 | Ok(()) 180 | } 181 | 182 | #[instrument(skip(self))] 183 | pub async fn enable_totp_with_token( 184 | &self, 185 | token: &str, 186 | pwd: &str, 187 | totp: &str, 188 | ) -> Result> { 189 | let claims: Token = self.token_app.decode(token).await?; 190 | self.token_app 191 | .verify(&claims, VerifyOptions::new(TokenKind::Session)) 192 | .await?; 193 | 194 | let user_id = claims.sub.parse().map_err(|err: ParseIntError| { 195 | warn!(error = err.to_string(), "parsing str to i32"); 196 | Error::InvalidToken 197 | })?; 198 | 199 | self.enable_totp(user_id, pwd, totp).await 200 | } 201 | 202 | #[instrument(skip(self))] 203 | pub async fn enable_totp(&self, user_id: i32, pwd: &str, totp: &str) -> Result> { 204 | let user = self 205 | .user_repo 206 | .find(user_id) 207 | .await 208 | .map_err(|_| Error::WrongCredentials)?; 209 | 210 | let pwd = crypto::obfuscate(pwd, self.pwd_sufix); 211 | if !user.match_password(&pwd) { 212 | return Err(Error::WrongCredentials); 213 | } 214 | 215 | // if, and only if, the user has activated the totp 216 | let mut secret_lookup = self 217 | .secret_repo 218 | .find_by_user_and_name(user.id, self.totp_secret_name) 219 | .await 220 | .ok(); 221 | 222 | if let Some(secret) = &mut secret_lookup { 223 | if !secret.is_deleted() { 224 | // the totp is already enabled 225 | return Err(Error::NotAvailable); 226 | } 227 | 228 | let data = secret.get_data(); 229 | if !crypto::verify_totp(data, totp)? { 230 | return Err(Error::Unauthorized); 231 | } 232 | 233 | secret.set_deleted_at(None); 234 | self.secret_repo.save(secret).await?; 235 | return Ok(None); 236 | } 237 | 238 | let token = crypto::get_random_string(self.totp_secret_len); 239 | let mut secret = Secret::new(&user, self.totp_secret_name, token.as_bytes()); 240 | secret.set_deleted_at(Some(Utc::now().naive_utc())); // unavailable till confirmed 241 | self.secret_repo.create(&mut secret).await?; 242 | Ok(Some(token)) 243 | } 244 | 245 | #[instrument(skip(self))] 246 | pub async fn disable_totp_with_token(&self, token: &str, pwd: &str, totp: &str) -> Result<()> { 247 | let claims: Token = self.token_app.decode(token).await?; 248 | self.token_app 249 | .verify(&claims, VerifyOptions::new(TokenKind::Session)) 250 | .await?; 251 | 252 | let user_id = claims.sub.parse().map_err(|err: ParseIntError| { 253 | warn!(error = err.to_string(), "parsing str to i32",); 254 | Error::InvalidToken 255 | })?; 256 | 257 | self.disable_totp(user_id, pwd, totp).await 258 | } 259 | 260 | #[instrument(skip(self))] 261 | pub async fn disable_totp(&self, user_id: i32, pwd: &str, totp: &str) -> Result<()> { 262 | let user = self 263 | .user_repo 264 | .find(user_id) 265 | .await 266 | .map_err(|_| Error::WrongCredentials)?; 267 | 268 | let pwd = crypto::obfuscate(pwd, self.pwd_sufix); 269 | if !user.match_password(&pwd) { 270 | return Err(Error::WrongCredentials); 271 | } 272 | 273 | // if, and only if, the user has activated the totp 274 | let mut secret_lookup = self 275 | .secret_repo 276 | .find_by_user_and_name(user.id, self.totp_secret_name) 277 | .await 278 | .ok(); 279 | 280 | if let Some(secret) = &mut secret_lookup { 281 | if secret.is_deleted() { 282 | // the totp is not enabled yet 283 | return Err(Error::NotAvailable); 284 | } 285 | 286 | let data = secret.get_data(); 287 | if !crypto::verify_totp(data, totp)? { 288 | return Err(Error::Unauthorized); 289 | } 290 | 291 | self.secret_repo.delete(secret).await?; 292 | return Ok(()); 293 | } 294 | 295 | Err(Error::NotAvailable) 296 | } 297 | 298 | #[instrument(skip(self))] 299 | pub async fn verify_reset_email(&self, email: &str) -> Result<()> { 300 | let user = match self.user_repo.find_by_email(email).await { 301 | Err(_) => return Ok(()), // returns Ok to not provide information about users 302 | Ok(user) => user, 303 | }; 304 | 305 | let token = self 306 | .token_app 307 | .generate( 308 | TokenKind::Reset, 309 | &user.get_id().to_string(), 310 | None, 311 | GenerateOptions::default(), 312 | ) 313 | .await?; 314 | 315 | self.mailer 316 | .send_verification_reset_email(email, token.signature())?; 317 | Ok(()) 318 | } 319 | 320 | #[instrument(skip(self))] 321 | pub async fn reset_with_token(&self, token: &str, new_pwd: &str, totp: &str) -> Result<()> { 322 | let claims: Token = self.token_app.decode(token).await?; 323 | self.token_app 324 | .verify(&claims, VerifyOptions::new(TokenKind::Reset)) 325 | .await?; 326 | 327 | let user_id = claims.sub.parse().map_err(|err: ParseIntError| { 328 | warn!(error = err.to_string(), "parsing str to i32",); 329 | Error::InvalidToken 330 | })?; 331 | 332 | self.reset(user_id, new_pwd, totp).await?; 333 | self.token_app.revoke(&claims).await?; 334 | Ok(()) 335 | } 336 | 337 | #[instrument(skip(self))] 338 | pub async fn reset(&self, user_id: i32, new_pwd: &str, totp: &str) -> Result<()> { 339 | let mut user = self 340 | .user_repo 341 | .find(user_id) 342 | .await 343 | .map_err(|_| Error::WrongCredentials)?; 344 | 345 | let new_pwd = crypto::obfuscate(new_pwd, self.pwd_sufix); 346 | if user.match_password(&new_pwd) { 347 | return Err(Error::WrongCredentials); 348 | } 349 | 350 | // if, and only if, the user has activated the totp 351 | if let Ok(secret) = self 352 | .secret_repo 353 | .find_by_user_and_name(user.get_id(), self.totp_secret_name) 354 | .await 355 | { 356 | if !secret.is_deleted() { 357 | let data = secret.get_data(); 358 | if !crypto::verify_totp(data, totp)? { 359 | return Err(Error::Unauthorized); 360 | } 361 | } 362 | } 363 | 364 | user.set_password(&new_pwd)?; 365 | self.user_repo.save(&user).await 366 | } 367 | } 368 | 369 | #[cfg(test)] 370 | pub mod tests { 371 | use super::super::domain::tests::{TEST_DEFAULT_USER_EMAIL, TEST_DEFAULT_USER_PASSWORD}; 372 | use super::super::domain::{tests::new_user_custom, User}; 373 | use super::{EventBus, UserApplication, UserRepository}; 374 | use crate::secret::{ 375 | application::tests::SecretRepositoryMock, 376 | domain::{ 377 | tests::{new_secret, TEST_DEFAULT_SECRET_DATA}, 378 | Secret, 379 | }, 380 | }; 381 | use crate::smtp::tests::MailerMock; 382 | use crate::token::application::tests::{new_token_application, PRIVATE_KEY, PUBLIC_KEY}; 383 | use crate::token::{ 384 | application::tests::TokenRepositoryMock, 385 | domain::{Token, TokenDefinition, TokenKind}, 386 | }; 387 | use crate::user::domain::tests::TEST_DEFAULT_PWD_SUFIX; 388 | use crate::{ 389 | crypto, 390 | result::{Error, Result}, 391 | }; 392 | use async_trait::async_trait; 393 | use chrono::Utc; 394 | use std::sync::Arc; 395 | use std::time::Duration; 396 | 397 | pub const TEST_CREATE_ID: i32 = 999; 398 | pub const TEST_FIND_BY_EMAIL_ID: i32 = 888; 399 | pub const TEST_FIND_BY_NAME_ID: i32 = 777; 400 | 401 | type MockFnFind = Option Result>; 402 | type MockFnFindByEmail = Option Result>; 403 | type MockFnFindByName = Option Result>; 404 | type MockFnCreate = Option Result<()>>; 405 | type MockFnSave = Option Result<()>>; 406 | type MockFnDelete = Option Result<()>>; 407 | 408 | #[derive(Default)] 409 | pub struct UserRepositoryMock { 410 | pub fn_find: MockFnFind, 411 | pub fn_find_by_email: MockFnFindByEmail, 412 | pub fn_find_by_name: MockFnFindByName, 413 | pub fn_create: MockFnCreate, 414 | pub fn_save: MockFnSave, 415 | pub fn_delete: MockFnDelete, 416 | } 417 | 418 | #[async_trait] 419 | impl UserRepository for UserRepositoryMock { 420 | async fn find(&self, id: i32) -> Result { 421 | if let Some(f) = self.fn_find { 422 | return f(self, id); 423 | } 424 | 425 | Ok(new_user_custom(id, "")) 426 | } 427 | 428 | async fn find_by_email(&self, email: &str) -> Result { 429 | if let Some(f) = self.fn_find_by_email { 430 | return f(self, email); 431 | } 432 | 433 | Ok(new_user_custom(TEST_FIND_BY_EMAIL_ID, email)) 434 | } 435 | 436 | async fn find_by_name(&self, name: &str) -> Result { 437 | if let Some(f) = self.fn_find_by_name { 438 | return f(self, name); 439 | } 440 | 441 | Ok(new_user_custom(TEST_FIND_BY_NAME_ID, name)) 442 | } 443 | 444 | async fn create(&self, user: &mut User) -> Result<()> { 445 | if let Some(f) = self.fn_create { 446 | return f(self, user); 447 | } 448 | 449 | user.id = TEST_CREATE_ID; 450 | Ok(()) 451 | } 452 | 453 | async fn save(&self, user: &User) -> Result<()> { 454 | if let Some(f) = self.fn_save { 455 | return f(self, user); 456 | } 457 | 458 | Ok(()) 459 | } 460 | 461 | async fn delete(&self, user: &User) -> Result<()> { 462 | if let Some(f) = self.fn_delete { 463 | return f(self, user); 464 | } 465 | 466 | Ok(()) 467 | } 468 | } 469 | 470 | type MockFnEmitUserCreated = Option Result<()>>; 471 | 472 | #[derive(Default)] 473 | pub struct EventBusMock { 474 | pub fn_emit_user_created: MockFnEmitUserCreated, 475 | } 476 | 477 | #[async_trait] 478 | impl EventBus for EventBusMock { 479 | async fn emit_user_created(&self, user: &User) -> Result<()> { 480 | if let Some(f) = self.fn_emit_user_created { 481 | return f(self, user); 482 | } 483 | 484 | Ok(()) 485 | } 486 | } 487 | 488 | pub fn new_user_application( 489 | token_repo: Option<&TokenRepositoryMock>, 490 | ) -> UserApplication< 491 | 'static, 492 | UserRepositoryMock, 493 | SecretRepositoryMock, 494 | TokenRepositoryMock, 495 | EventBusMock, 496 | MailerMock, 497 | > { 498 | let user_repo = UserRepositoryMock::default(); 499 | let secret_repo = SecretRepositoryMock::default(); 500 | let mailer_mock = MailerMock::default(); 501 | let token_app = new_token_application(token_repo.cloned()); 502 | 503 | let event_bus = EventBusMock::default(); 504 | UserApplication { 505 | user_repo: Arc::new(user_repo), 506 | secret_repo: Arc::new(secret_repo), 507 | token_app: Arc::new(token_app), 508 | mailer: Arc::new(mailer_mock), 509 | event_bus: Arc::new(event_bus), 510 | totp_secret_len: 32_usize, 511 | totp_secret_name: ".dummy_totp_secret", 512 | pwd_sufix: TEST_DEFAULT_PWD_SUFIX, 513 | } 514 | } 515 | 516 | #[tokio::test] 517 | async fn user_verify_should_not_fail() { 518 | let user_repo = UserRepositoryMock { 519 | fn_find_by_email: Some(|_: &UserRepositoryMock, _: &str| -> Result { 520 | Err(Error::NotFound) 521 | }), 522 | ..Default::default() 523 | }; 524 | 525 | let mut app = new_user_application(None); 526 | app.user_repo = Arc::new(user_repo); 527 | 528 | app.verify_signup_email(TEST_DEFAULT_USER_EMAIL, TEST_DEFAULT_USER_PASSWORD) 529 | .await 530 | .unwrap(); 531 | } 532 | 533 | #[tokio::test] 534 | async fn user_verify_already_exists_should_not_fail() { 535 | let app = new_user_application(None); 536 | app.verify_signup_email(TEST_DEFAULT_USER_EMAIL, TEST_DEFAULT_USER_PASSWORD) 537 | .await 538 | .unwrap(); 539 | } 540 | 541 | #[tokio::test] 542 | async fn user_verify_wrong_email_should_fail() { 543 | let user_repo = UserRepositoryMock { 544 | fn_find_by_email: Some(|_: &UserRepositoryMock, _: &str| -> Result { 545 | Err(Error::NotFound) 546 | }), 547 | ..Default::default() 548 | }; 549 | 550 | let mut app = new_user_application(None); 551 | app.user_repo = Arc::new(user_repo); 552 | 553 | app.verify_signup_email("this is not an email", TEST_DEFAULT_USER_PASSWORD) 554 | .await 555 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidFormat.to_string())) 556 | .unwrap_err(); 557 | } 558 | 559 | #[tokio::test] 560 | async fn user_secure_signup_should_not_fail() { 561 | let user_repo = UserRepositoryMock { 562 | fn_find_by_email: Some(|_: &UserRepositoryMock, _: &str| -> Result { 563 | Err(Error::NotFound) 564 | }), 565 | ..Default::default() 566 | }; 567 | 568 | let token_to_keep = Token::new( 569 | "test", 570 | TEST_DEFAULT_USER_EMAIL, 571 | Duration::from_secs(60), 572 | TokenKind::Verification, 573 | Some(TEST_DEFAULT_USER_PASSWORD), 574 | ); 575 | 576 | let token_to_send = Token::new( 577 | "test", 578 | &token_to_keep.get_id(), 579 | Duration::from_secs(60), 580 | TokenKind::Verification, 581 | None, 582 | ); 583 | 584 | let token_to_keep = crypto::sign_jwt(&PRIVATE_KEY, token_to_keep).unwrap(); 585 | let token_to_send = crypto::sign_jwt(&PRIVATE_KEY, token_to_send).unwrap(); 586 | 587 | let token_repo = TokenRepositoryMock { 588 | token: token_to_keep.clone(), 589 | fn_find: Some(|this: &TokenRepositoryMock, tid: &str| -> Result { 590 | let claims: Token = crypto::decode_jwt(&PUBLIC_KEY, &this.token)?; 591 | assert_eq!(claims.get_id(), tid); 592 | 593 | Ok(this.token.clone()) 594 | }), 595 | ..Default::default() 596 | }; 597 | let mut app = new_user_application(Some(&token_repo)); 598 | app.user_repo = Arc::new(user_repo); 599 | 600 | let token = app.signup_with_token(&token_to_send).await.unwrap(); 601 | let claims: Token = crypto::decode_jwt(&PUBLIC_KEY, &token).unwrap(); 602 | assert_eq!(claims.sub, TEST_CREATE_ID.to_string()); 603 | } 604 | 605 | #[tokio::test] 606 | async fn user_secure_signup_verification_token_kind_should_fail() { 607 | let user_repo = UserRepositoryMock { 608 | fn_find_by_email: Some(|_: &UserRepositoryMock, _: &str| -> Result { 609 | Err(Error::NotFound) 610 | }), 611 | ..Default::default() 612 | }; 613 | 614 | let token = Token::new( 615 | "test", 616 | "test", 617 | Duration::from_secs(60), 618 | TokenKind::Verification, 619 | None, 620 | ); 621 | 622 | let token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 623 | let mut app = new_user_application(None); 624 | app.user_repo = Arc::new(user_repo); 625 | 626 | app.signup_with_token(&token) 627 | .await 628 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 629 | .unwrap_err(); 630 | } 631 | 632 | #[tokio::test] 633 | async fn user_secure_signup_reset_token_kind_should_fail() { 634 | let user_repo = UserRepositoryMock { 635 | fn_find_by_email: Some(|_: &UserRepositoryMock, _: &str| -> Result { 636 | Err(Error::NotFound) 637 | }), 638 | ..Default::default() 639 | }; 640 | 641 | let token = Token::new( 642 | "test", 643 | "test", 644 | Duration::from_secs(60), 645 | TokenKind::Reset, 646 | None, 647 | ); 648 | 649 | let token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 650 | let mut app = new_user_application(None); 651 | app.user_repo = Arc::new(user_repo); 652 | 653 | app.signup_with_token(&token) 654 | .await 655 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 656 | .unwrap_err(); 657 | } 658 | 659 | #[tokio::test] 660 | async fn user_signup_should_not_fail() { 661 | let user_repo = UserRepositoryMock { 662 | fn_find_by_email: Some(|_: &UserRepositoryMock, _: &str| -> Result { 663 | Err(Error::Unknown) 664 | }), 665 | ..Default::default() 666 | }; 667 | 668 | let mut app = new_user_application(None); 669 | app.user_repo = Arc::new(user_repo); 670 | 671 | let token = app 672 | .signup(TEST_DEFAULT_USER_EMAIL, TEST_DEFAULT_USER_PASSWORD) 673 | .await 674 | .unwrap(); 675 | let claims: Token = crypto::decode_jwt(&PUBLIC_KEY, &token).unwrap(); 676 | assert_eq!(claims.sub, TEST_CREATE_ID.to_string()); 677 | } 678 | 679 | #[tokio::test] 680 | async fn user_signup_wrong_email_should_fail() { 681 | let app = new_user_application(None); 682 | app.signup("this is not an email", TEST_DEFAULT_USER_PASSWORD) 683 | .await 684 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidFormat.to_string())) 685 | .unwrap_err(); 686 | } 687 | 688 | #[tokio::test] 689 | async fn user_signup_wrong_password_should_fail() { 690 | let app = new_user_application(None); 691 | app.signup(TEST_DEFAULT_USER_EMAIL, "bad password") 692 | .await 693 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidFormat.to_string())) 694 | .unwrap_err(); 695 | } 696 | 697 | #[tokio::test] 698 | async fn user_signup_already_exists_should_not_fail() { 699 | let app = new_user_application(None); 700 | app.signup(TEST_DEFAULT_USER_EMAIL, TEST_DEFAULT_USER_PASSWORD) 701 | .await 702 | .unwrap(); 703 | } 704 | 705 | #[tokio::test] 706 | async fn user_secure_delete_should_not_fail() { 707 | let secret_repo = SecretRepositoryMock { 708 | fn_find_by_user_and_name: Some( 709 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 710 | Err(Error::NotFound) 711 | }, 712 | ), 713 | ..Default::default() 714 | }; 715 | 716 | let token = Token::new( 717 | "test", 718 | "0", 719 | Duration::from_secs(60), 720 | TokenKind::Session, 721 | None, 722 | ); 723 | 724 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 725 | let token_repo = TokenRepositoryMock { 726 | token: secure_token.clone(), 727 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 728 | Ok(this.token.clone()) 729 | }), 730 | ..Default::default() 731 | }; 732 | 733 | let mut app = new_user_application(Some(&token_repo)); 734 | app.secret_repo = Arc::new(secret_repo); 735 | 736 | app.delete_with_token(&secure_token, TEST_DEFAULT_USER_PASSWORD, "") 737 | .await 738 | .unwrap(); 739 | } 740 | 741 | #[tokio::test] 742 | async fn user_secure_delete_verification_token_kind_should_fail() { 743 | let secret_repo = SecretRepositoryMock { 744 | fn_find_by_user_and_name: Some( 745 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 746 | Err(Error::NotFound) 747 | }, 748 | ), 749 | ..Default::default() 750 | }; 751 | 752 | let token = Token::new( 753 | "test", 754 | "0", 755 | Duration::from_secs(60), 756 | TokenKind::Verification, 757 | None, 758 | ); 759 | 760 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 761 | let token_repo = TokenRepositoryMock { 762 | token: secure_token.clone(), 763 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 764 | Ok(this.token.clone()) 765 | }), 766 | ..Default::default() 767 | }; 768 | 769 | let mut app = new_user_application(Some(&token_repo)); 770 | app.secret_repo = Arc::new(secret_repo); 771 | 772 | app.delete_with_token(&secure_token, TEST_DEFAULT_USER_PASSWORD, "") 773 | .await 774 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 775 | .unwrap_err(); 776 | } 777 | 778 | #[tokio::test] 779 | async fn user_secure_delete_reset_token_kind_should_fail() { 780 | let secret_repo = SecretRepositoryMock { 781 | fn_find_by_user_and_name: Some( 782 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 783 | Err(Error::NotFound) 784 | }, 785 | ), 786 | ..Default::default() 787 | }; 788 | 789 | let token = Token::new("test", "0", Duration::from_secs(60), TokenKind::Reset, None); 790 | 791 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 792 | let token_repo = TokenRepositoryMock { 793 | token: secure_token.clone(), 794 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 795 | Ok(this.token.clone()) 796 | }), 797 | ..Default::default() 798 | }; 799 | 800 | let mut app = new_user_application(Some(&token_repo)); 801 | app.secret_repo = Arc::new(secret_repo); 802 | 803 | app.delete_with_token(&secure_token, TEST_DEFAULT_USER_PASSWORD, "") 804 | .await 805 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 806 | .unwrap_err(); 807 | } 808 | 809 | #[tokio::test] 810 | async fn user_delete_should_not_fail() { 811 | let secret_repo = SecretRepositoryMock { 812 | fn_find_by_user_and_name: Some( 813 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 814 | Err(Error::NotFound) 815 | }, 816 | ), 817 | ..Default::default() 818 | }; 819 | 820 | let mut app = new_user_application(None); 821 | app.secret_repo = Arc::new(secret_repo); 822 | 823 | app.delete(0, TEST_DEFAULT_USER_PASSWORD, "").await.unwrap(); 824 | } 825 | 826 | #[tokio::test] 827 | async fn user_delete_totp_should_not_fail() { 828 | let app = new_user_application(None); 829 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 830 | .unwrap() 831 | .generate(); 832 | app.delete(0, TEST_DEFAULT_USER_PASSWORD, &code) 833 | .await 834 | .unwrap(); 835 | } 836 | 837 | #[tokio::test] 838 | async fn user_delete_not_found_should_fail() { 839 | let user_repo = UserRepositoryMock { 840 | fn_find: Some(|_: &UserRepositoryMock, _: i32| -> Result { 841 | Err(Error::NotFound) 842 | }), 843 | ..Default::default() 844 | }; 845 | 846 | let secret_repo = SecretRepositoryMock { 847 | fn_find_by_user_and_name: Some( 848 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 849 | Err(Error::NotFound) 850 | }, 851 | ), 852 | ..Default::default() 853 | }; 854 | 855 | let mut app = new_user_application(None); 856 | app.user_repo = Arc::new(user_repo); 857 | app.secret_repo = Arc::new(secret_repo); 858 | 859 | app.delete(0, TEST_DEFAULT_USER_PASSWORD, "") 860 | .await 861 | .map_err(|err| assert_eq!(err.to_string(), Error::WrongCredentials.to_string())) 862 | .unwrap_err(); 863 | } 864 | 865 | #[tokio::test] 866 | async fn user_delete_wrong_password_should_fail() { 867 | let secret_repo = SecretRepositoryMock { 868 | fn_find_by_user_and_name: Some( 869 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 870 | Err(Error::NotFound) 871 | }, 872 | ), 873 | ..Default::default() 874 | }; 875 | 876 | let mut app = new_user_application(None); 877 | app.secret_repo = Arc::new(secret_repo); 878 | 879 | app.delete(0, "bad password", "") 880 | .await 881 | .map_err(|err| assert_eq!(err.to_string(), Error::WrongCredentials.to_string())) 882 | .unwrap_err(); 883 | } 884 | 885 | #[tokio::test] 886 | async fn user_delete_wrong_totp_should_fail() { 887 | let app = new_user_application(None); 888 | app.delete(0, TEST_DEFAULT_USER_PASSWORD, "bad totp") 889 | .await 890 | .map_err(|err| assert_eq!(err.to_string(), Error::Unauthorized.to_string())) 891 | .unwrap_err(); 892 | } 893 | 894 | #[tokio::test] 895 | async fn user_secure_enable_totp_should_not_fail() { 896 | let secret_repo = SecretRepositoryMock { 897 | fn_find_by_user_and_name: Some( 898 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 899 | Err(Error::NotFound) 900 | }, 901 | ), 902 | ..Default::default() 903 | }; 904 | 905 | let token = Token::new( 906 | "test", 907 | "0", 908 | Duration::from_secs(60), 909 | TokenKind::Session, 910 | None, 911 | ); 912 | 913 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 914 | let token_repo = TokenRepositoryMock { 915 | token: secure_token.clone(), 916 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 917 | Ok(this.token.clone()) 918 | }), 919 | ..Default::default() 920 | }; 921 | 922 | let mut app = new_user_application(Some(&token_repo)); 923 | app.secret_repo = Arc::new(secret_repo); 924 | 925 | let totp = app 926 | .enable_totp_with_token(&secure_token, TEST_DEFAULT_USER_PASSWORD, "") 927 | .await 928 | .unwrap(); 929 | assert!(totp.is_some()); 930 | assert_eq!(totp.unwrap().len(), app.totp_secret_len); 931 | } 932 | 933 | #[tokio::test] 934 | async fn user_secure_enable_totp_verification_token_kind_should_fail() { 935 | let secret_repo = SecretRepositoryMock { 936 | fn_find_by_user_and_name: Some( 937 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 938 | Err(Error::NotFound) 939 | }, 940 | ), 941 | ..Default::default() 942 | }; 943 | 944 | let token = Token::new( 945 | "test", 946 | "0", 947 | Duration::from_secs(60), 948 | TokenKind::Verification, 949 | None, 950 | ); 951 | 952 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 953 | let token_repo = TokenRepositoryMock { 954 | token: secure_token.clone(), 955 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 956 | Ok(this.token.clone()) 957 | }), 958 | ..Default::default() 959 | }; 960 | 961 | let mut app = new_user_application(Some(&token_repo)); 962 | app.secret_repo = Arc::new(secret_repo); 963 | 964 | app.enable_totp_with_token(&secure_token, TEST_DEFAULT_USER_PASSWORD, "") 965 | .await 966 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 967 | .unwrap_err(); 968 | } 969 | 970 | #[tokio::test] 971 | async fn user_secure_enable_totp_reset_token_kind_should_fail() { 972 | let secret_repo = SecretRepositoryMock { 973 | fn_find_by_user_and_name: Some( 974 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 975 | Err(Error::NotFound) 976 | }, 977 | ), 978 | ..Default::default() 979 | }; 980 | 981 | let token = Token::new("test", "0", Duration::from_secs(60), TokenKind::Reset, None); 982 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 983 | let token_repo = TokenRepositoryMock { 984 | token: secure_token.clone(), 985 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 986 | Ok(this.token.clone()) 987 | }), 988 | ..Default::default() 989 | }; 990 | 991 | let mut app = new_user_application(Some(&token_repo)); 992 | app.secret_repo = Arc::new(secret_repo); 993 | 994 | app.enable_totp_with_token(&secure_token, TEST_DEFAULT_USER_PASSWORD, "") 995 | .await 996 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 997 | .unwrap_err(); 998 | } 999 | 1000 | #[tokio::test] 1001 | async fn user_enable_totp_should_not_fail() { 1002 | let mut secret_repo = SecretRepositoryMock { 1003 | fn_find_by_user_and_name: Some( 1004 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 1005 | Err(Error::NotFound) 1006 | }, 1007 | ), 1008 | ..Default::default() 1009 | }; 1010 | 1011 | secret_repo.fn_save = Some(|_: &SecretRepositoryMock, secret: &Secret| -> Result<()> { 1012 | if !secret.is_deleted() { 1013 | return Err(Error::Unknown); 1014 | } 1015 | 1016 | Ok(()) 1017 | }); 1018 | 1019 | let mut app = new_user_application(None); 1020 | app.secret_repo = Arc::new(secret_repo); 1021 | 1022 | let totp = app 1023 | .enable_totp(0, TEST_DEFAULT_USER_PASSWORD, "") 1024 | .await 1025 | .unwrap(); 1026 | assert!(totp.is_some()); 1027 | assert_eq!(totp.unwrap().len(), app.totp_secret_len); 1028 | } 1029 | 1030 | #[tokio::test] 1031 | async fn user_enable_totp_verify_should_not_fail() { 1032 | let secret_repo = SecretRepositoryMock { 1033 | fn_find_by_user_and_name: Some( 1034 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 1035 | let mut secret = new_secret(); 1036 | secret.set_deleted_at(Some(Utc::now().naive_utc())); 1037 | Ok(secret) 1038 | }, 1039 | ), 1040 | fn_save: Some(|_: &SecretRepositoryMock, secret: &Secret| -> Result<()> { 1041 | if secret.is_deleted() { 1042 | return Err(Error::Unknown); 1043 | } 1044 | 1045 | Ok(()) 1046 | }), 1047 | ..Default::default() 1048 | }; 1049 | 1050 | let mut app = new_user_application(None); 1051 | app.secret_repo = Arc::new(secret_repo); 1052 | 1053 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 1054 | .unwrap() 1055 | .generate(); 1056 | let totp = app 1057 | .enable_totp(0, TEST_DEFAULT_USER_PASSWORD, &code) 1058 | .await 1059 | .unwrap(); 1060 | assert_eq!(totp, None); 1061 | } 1062 | 1063 | #[tokio::test] 1064 | async fn user_enable_totp_wrong_password_should_fail() { 1065 | let secret_repo = SecretRepositoryMock { 1066 | fn_find_by_user_and_name: Some( 1067 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 1068 | let mut secret = new_secret(); 1069 | secret.set_deleted_at(Some(Utc::now().naive_utc())); 1070 | Ok(secret) 1071 | }, 1072 | ), 1073 | ..Default::default() 1074 | }; 1075 | 1076 | let mut app = new_user_application(None); 1077 | app.secret_repo = Arc::new(secret_repo); 1078 | 1079 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 1080 | .unwrap() 1081 | .generate(); 1082 | app.enable_totp(0, "bad password", &code) 1083 | .await 1084 | .map_err(|err| assert_eq!(err.to_string(), Error::WrongCredentials.to_string())) 1085 | .unwrap_err(); 1086 | } 1087 | 1088 | #[tokio::test] 1089 | async fn user_enable_totp_already_enabled_should_fail() { 1090 | let app = new_user_application(None); 1091 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 1092 | .unwrap() 1093 | .generate(); 1094 | app.enable_totp(0, TEST_DEFAULT_USER_PASSWORD, &code) 1095 | .await 1096 | .map_err(|err| assert_eq!(err.to_string(), Error::NotAvailable.to_string())) 1097 | .unwrap_err(); 1098 | } 1099 | 1100 | #[tokio::test] 1101 | async fn user_secure_disable_totp_should_not_fail() { 1102 | let token = Token::new( 1103 | "test", 1104 | "0", 1105 | Duration::from_secs(60), 1106 | TokenKind::Session, 1107 | None, 1108 | ); 1109 | 1110 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 1111 | let token_repo = TokenRepositoryMock { 1112 | token: secure_token.clone(), 1113 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 1114 | Ok(this.token.clone()) 1115 | }), 1116 | ..Default::default() 1117 | }; 1118 | 1119 | let app = new_user_application(Some(&token_repo)); 1120 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 1121 | .unwrap() 1122 | .generate(); 1123 | app.disable_totp_with_token(&secure_token, TEST_DEFAULT_USER_PASSWORD, &code) 1124 | .await 1125 | .unwrap(); 1126 | } 1127 | 1128 | #[tokio::test] 1129 | async fn user_secure_disable_totp_verification_token_kind_should_fail() { 1130 | let token = Token::new( 1131 | "test", 1132 | "0", 1133 | Duration::from_secs(60), 1134 | TokenKind::Verification, 1135 | None, 1136 | ); 1137 | 1138 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 1139 | let token_repo = TokenRepositoryMock { 1140 | token: secure_token.clone(), 1141 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 1142 | Ok(this.token.clone()) 1143 | }), 1144 | ..Default::default() 1145 | }; 1146 | 1147 | let app = new_user_application(Some(&token_repo)); 1148 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 1149 | .unwrap() 1150 | .generate(); 1151 | app.disable_totp_with_token(&secure_token, TEST_DEFAULT_USER_PASSWORD, &code) 1152 | .await 1153 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 1154 | .unwrap_err(); 1155 | } 1156 | 1157 | #[tokio::test] 1158 | async fn user_secure_disable_totp_reset_token_kind_should_fail() { 1159 | let token = Token::new("test", "0", Duration::from_secs(60), TokenKind::Reset, None); 1160 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 1161 | let token_repo = TokenRepositoryMock { 1162 | token: secure_token.clone(), 1163 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 1164 | Ok(this.token.clone()) 1165 | }), 1166 | ..Default::default() 1167 | }; 1168 | 1169 | let app = new_user_application(Some(&token_repo)); 1170 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 1171 | .unwrap() 1172 | .generate(); 1173 | app.disable_totp_with_token(&secure_token, TEST_DEFAULT_USER_PASSWORD, &code) 1174 | .await 1175 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 1176 | .unwrap_err(); 1177 | } 1178 | 1179 | #[tokio::test] 1180 | async fn user_disable_totp_should_not_fail() { 1181 | let app = new_user_application(None); 1182 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 1183 | .unwrap() 1184 | .generate(); 1185 | app.disable_totp(0, TEST_DEFAULT_USER_PASSWORD, &code) 1186 | .await 1187 | .unwrap(); 1188 | } 1189 | 1190 | #[tokio::test] 1191 | async fn user_disable_totp_wrong_password_should_fail() { 1192 | let app = new_user_application(None); 1193 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 1194 | .unwrap() 1195 | .generate(); 1196 | app.disable_totp(0, "bad password", &code) 1197 | .await 1198 | .map_err(|err| assert_eq!(err.to_string(), Error::WrongCredentials.to_string())) 1199 | .unwrap_err(); 1200 | } 1201 | 1202 | #[tokio::test] 1203 | async fn user_disable_totp_wrong_totp_should_fail() { 1204 | let app = new_user_application(None); 1205 | app.disable_totp(0, TEST_DEFAULT_USER_PASSWORD, "bad totp") 1206 | .await 1207 | .map_err(|err| assert_eq!(err.to_string(), Error::Unauthorized.to_string())) 1208 | .unwrap_err(); 1209 | } 1210 | 1211 | #[tokio::test] 1212 | async fn user_disable_totp_not_enabled_should_fail() { 1213 | let secret_repo = SecretRepositoryMock { 1214 | fn_find_by_user_and_name: Some( 1215 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 1216 | Err(Error::NotFound) 1217 | }, 1218 | ), 1219 | ..Default::default() 1220 | }; 1221 | 1222 | let mut app = new_user_application(None); 1223 | app.secret_repo = Arc::new(secret_repo); 1224 | 1225 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 1226 | .unwrap() 1227 | .generate(); 1228 | app.disable_totp(0, TEST_DEFAULT_USER_PASSWORD, &code) 1229 | .await 1230 | .map_err(|err| assert_eq!(err.to_string(), Error::NotAvailable.to_string())) 1231 | .unwrap_err(); 1232 | } 1233 | 1234 | #[tokio::test] 1235 | async fn user_disable_totp_not_verified_should_fail() { 1236 | let secret_repo = SecretRepositoryMock { 1237 | fn_find_by_user_and_name: Some( 1238 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 1239 | let mut secret = new_secret(); 1240 | secret.set_deleted_at(Some(Utc::now().naive_utc())); 1241 | Ok(secret) 1242 | }, 1243 | ), 1244 | ..Default::default() 1245 | }; 1246 | 1247 | let mut app = new_user_application(None); 1248 | app.secret_repo = Arc::new(secret_repo); 1249 | 1250 | let code = crypto::generate_totp(TEST_DEFAULT_SECRET_DATA.as_bytes()) 1251 | .unwrap() 1252 | .generate(); 1253 | app.disable_totp(0, TEST_DEFAULT_USER_PASSWORD, &code) 1254 | .await 1255 | .map_err(|err| assert_eq!(err.to_string(), Error::NotAvailable.to_string())) 1256 | .unwrap_err(); 1257 | } 1258 | 1259 | #[tokio::test] 1260 | async fn user_secure_reset_should_not_fail() { 1261 | let secret_repo = SecretRepositoryMock { 1262 | fn_find_by_user_and_name: Some( 1263 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 1264 | Err(Error::NotFound) 1265 | }, 1266 | ), 1267 | ..Default::default() 1268 | }; 1269 | 1270 | let token = Token::new("test", "0", Duration::from_secs(60), TokenKind::Reset, None); 1271 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 1272 | let token_repo = TokenRepositoryMock { 1273 | token: secure_token.clone(), 1274 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 1275 | Ok(this.token.clone()) 1276 | }), 1277 | ..Default::default() 1278 | }; 1279 | 1280 | let mut app = new_user_application(Some(&token_repo)); 1281 | app.secret_repo = Arc::new(secret_repo); 1282 | 1283 | app.reset_with_token(&secure_token, "ABCDEF1234567891", "") 1284 | .await 1285 | .unwrap(); 1286 | } 1287 | 1288 | #[tokio::test] 1289 | async fn user_secure_reset_verification_token_kind_should_fail() { 1290 | let secret_repo = SecretRepositoryMock { 1291 | fn_find_by_user_and_name: Some( 1292 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 1293 | Err(Error::NotFound) 1294 | }, 1295 | ), 1296 | ..Default::default() 1297 | }; 1298 | 1299 | let token = Token::new( 1300 | "test", 1301 | "0", 1302 | Duration::from_secs(60), 1303 | TokenKind::Verification, 1304 | None, 1305 | ); 1306 | 1307 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 1308 | let token_repo = TokenRepositoryMock { 1309 | token: secure_token.clone(), 1310 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 1311 | Ok(this.token.clone()) 1312 | }), 1313 | ..Default::default() 1314 | }; 1315 | 1316 | let mut app = new_user_application(Some(&token_repo)); 1317 | app.secret_repo = Arc::new(secret_repo); 1318 | 1319 | app.reset_with_token(&secure_token, "another password", "") 1320 | .await 1321 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 1322 | .unwrap_err(); 1323 | } 1324 | 1325 | #[tokio::test] 1326 | async fn user_secure_reset_session_token_kind_should_fail() { 1327 | let secret_repo = SecretRepositoryMock { 1328 | fn_find_by_user_and_name: Some( 1329 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 1330 | Err(Error::NotFound) 1331 | }, 1332 | ), 1333 | ..Default::default() 1334 | }; 1335 | 1336 | let token = Token::new( 1337 | "test", 1338 | "0", 1339 | Duration::from_secs(60), 1340 | TokenKind::Session, 1341 | None, 1342 | ); 1343 | 1344 | let secure_token = crypto::sign_jwt(&PRIVATE_KEY, token).unwrap(); 1345 | let token_repo = TokenRepositoryMock { 1346 | token: secure_token.clone(), 1347 | fn_find: Some(|this: &TokenRepositoryMock, _: &str| -> Result { 1348 | Ok(this.token.clone()) 1349 | }), 1350 | ..Default::default() 1351 | }; 1352 | 1353 | let mut app = new_user_application(Some(&token_repo)); 1354 | app.secret_repo = Arc::new(secret_repo); 1355 | 1356 | app.reset_with_token(&secure_token, "another password", "") 1357 | .await 1358 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidToken.to_string())) 1359 | .unwrap_err(); 1360 | } 1361 | 1362 | #[tokio::test] 1363 | async fn user_reset_should_not_fail() { 1364 | let secret_repo = SecretRepositoryMock { 1365 | fn_find_by_user_and_name: Some( 1366 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 1367 | Err(Error::NotFound) 1368 | }, 1369 | ), 1370 | ..Default::default() 1371 | }; 1372 | 1373 | let mut app = new_user_application(None); 1374 | app.secret_repo = Arc::new(secret_repo); 1375 | 1376 | app.reset(0, "ABCDEF12345678901", "").await.unwrap(); 1377 | } 1378 | 1379 | #[tokio::test] 1380 | async fn user_reset_same_password_should_fail() { 1381 | let secret_repo = SecretRepositoryMock { 1382 | fn_find_by_user_and_name: Some( 1383 | |_: &SecretRepositoryMock, _: i32, _: &str| -> Result { 1384 | Err(Error::NotFound) 1385 | }, 1386 | ), 1387 | ..Default::default() 1388 | }; 1389 | 1390 | let mut app = new_user_application(None); 1391 | app.secret_repo = Arc::new(secret_repo); 1392 | 1393 | app.reset(0, TEST_DEFAULT_USER_PASSWORD, "") 1394 | .await 1395 | .map_err(|err| assert_eq!(err.to_string(), Error::WrongCredentials.to_string())) 1396 | .unwrap_err(); 1397 | } 1398 | } 1399 | -------------------------------------------------------------------------------- /src/user/domain.rs: -------------------------------------------------------------------------------- 1 | use crate::metadata::domain::Metadata; 2 | use crate::{ 3 | email, regex, 4 | result::{Error, Result}, 5 | }; 6 | 7 | /// Represents a user and all its personal data 8 | #[derive(Debug)] 9 | pub struct User { 10 | pub(super) id: i32, 11 | pub(super) name: String, 12 | pub(super) email: String, 13 | pub(super) actual_email: String, 14 | pub(super) password: String, 15 | pub(super) meta: Metadata, 16 | } 17 | 18 | impl User { 19 | pub fn new(email: &str, password: &str) -> Result { 20 | regex::match_regex(regex::EMAIL, email).map_err(|err| { 21 | warn!(error = err.to_string(), "validating email's format",); 22 | Error::InvalidFormat 23 | })?; 24 | 25 | regex::match_regex(regex::BASE64, password).map_err(|err| { 26 | warn!(error = err.to_string(), "validating password's format",); 27 | Error::InvalidFormat 28 | })?; 29 | 30 | let user = User { 31 | id: 0, 32 | name: email.to_string(), 33 | email: email.to_string(), 34 | actual_email: email::actual_email(email), 35 | password: password.to_string(), 36 | meta: Metadata::default(), 37 | }; 38 | 39 | Ok(user) 40 | } 41 | 42 | pub fn get_id(&self) -> i32 { 43 | self.id 44 | } 45 | 46 | pub fn get_email(&self) -> &str { 47 | &self.email 48 | } 49 | 50 | pub fn get_name(&self) -> &str { 51 | &self.name 52 | } 53 | 54 | pub fn match_password(&self, password: &str) -> bool { 55 | password == self.password 56 | } 57 | 58 | pub fn set_password(&mut self, password: &str) -> Result<()> { 59 | regex::match_regex(regex::BASE64, password).map_err(|err| { 60 | info!(error = err.to_string(), "validating password's format",); 61 | Error::InvalidFormat 62 | })?; 63 | 64 | self.password = password.to_string(); 65 | Ok(()) 66 | } 67 | } 68 | 69 | #[cfg(test)] 70 | pub mod tests { 71 | use super::User; 72 | use crate::metadata::domain::tests::new_metadata; 73 | use crate::result::Error; 74 | use crate::{crypto, email}; 75 | 76 | pub const TEST_DEFAULT_USER_ID: i32 = 999; 77 | pub const TEST_DEFAULT_USER_NAME: &str = "dummyuser"; 78 | pub const TEST_DEFAULT_USER_EMAIL: &str = "dummy@test.com"; 79 | pub const TEST_DEFAULT_USER_PASSWORD: &str = "ABCDEF1234567890"; 80 | pub const TEST_DEFAULT_PWD_SUFIX: &str = "sufix"; 81 | 82 | pub fn new_user() -> User { 83 | User { 84 | id: TEST_DEFAULT_USER_ID, 85 | name: TEST_DEFAULT_USER_NAME.to_string(), 86 | email: TEST_DEFAULT_USER_EMAIL.to_string(), 87 | actual_email: email::actual_email(TEST_DEFAULT_USER_EMAIL), 88 | password: TEST_DEFAULT_USER_PASSWORD.to_string(), 89 | meta: new_metadata(), 90 | } 91 | } 92 | 93 | pub fn new_user_custom(id: i32, email: &str) -> User { 94 | User { 95 | id, 96 | name: "custom_user".to_string(), 97 | email: email.to_string(), 98 | actual_email: email::actual_email(email), 99 | password: crypto::obfuscate(TEST_DEFAULT_USER_PASSWORD, TEST_DEFAULT_PWD_SUFIX), 100 | meta: new_metadata(), 101 | } 102 | } 103 | 104 | #[test] 105 | fn user_new_should_not_fail() { 106 | let user = User::new(TEST_DEFAULT_USER_EMAIL, TEST_DEFAULT_USER_PASSWORD).unwrap(); 107 | 108 | assert_eq!(user.id, 0); 109 | assert_eq!(user.name, TEST_DEFAULT_USER_EMAIL); 110 | assert_eq!(user.email, TEST_DEFAULT_USER_EMAIL); 111 | assert_eq!(user.password, TEST_DEFAULT_USER_PASSWORD); 112 | } 113 | 114 | #[test] 115 | fn user_new_wrong_email_should_fail() { 116 | const EMAIL: &str = "not_an_email"; 117 | 118 | let result = User::new(EMAIL, TEST_DEFAULT_USER_PASSWORD) 119 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidFormat.to_string())); 120 | 121 | assert!(result.is_err()); 122 | } 123 | 124 | #[test] 125 | fn user_new_wrong_password_should_fail() { 126 | const PWD: &str = "ABCDEFG1234567890"; 127 | 128 | let result = User::new(TEST_DEFAULT_USER_EMAIL, PWD) 129 | .map_err(|err| assert_eq!(err.to_string(), Error::InvalidFormat.to_string())); 130 | 131 | assert!(result.is_err()); 132 | } 133 | 134 | #[test] 135 | fn user_match_password_should_not_fail() { 136 | let user = new_user(); 137 | assert!(user.match_password(TEST_DEFAULT_USER_PASSWORD)); 138 | } 139 | 140 | #[test] 141 | fn user_match_password_should_fail() { 142 | let user = new_user(); 143 | assert!(!user.match_password("wrong password")); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/user/event_bus.rs: -------------------------------------------------------------------------------- 1 | use super::{application::EventBus, domain::User}; 2 | use crate::{ 3 | rabbitmq::EventKind, 4 | result::{Error, Result}, 5 | }; 6 | use async_trait::async_trait; 7 | use deadpool_lapin::Pool; 8 | use lapin::{options::*, BasicProperties}; 9 | use serde_json; 10 | 11 | #[derive(Serialize, Deserialize)] 12 | struct UserEventPayload<'a> { 13 | pub(super) user_id: i32, 14 | pub(super) user_name: &'a str, 15 | pub(super) user_email: &'a str, 16 | pub(super) event_issuer: &'a str, 17 | pub(super) event_kind: EventKind, 18 | } 19 | 20 | pub struct RabbitMqUserBus<'a> { 21 | pub pool: &'a Pool, 22 | pub exchange: &'a str, 23 | pub issuer: &'a str, 24 | } 25 | 26 | #[async_trait] 27 | impl<'a> EventBus for RabbitMqUserBus<'a> { 28 | #[instrument(skip(self))] 29 | async fn emit_user_created(&self, user: &User) -> Result<()> { 30 | let event = UserEventPayload { 31 | user_id: user.get_id(), 32 | user_name: user.get_name().split('@').collect::>()[0], 33 | user_email: user.get_email(), 34 | event_issuer: self.issuer, 35 | event_kind: EventKind::Created, 36 | }; 37 | 38 | let payload = serde_json::to_string(&event) 39 | .map(|str| str.into_bytes()) 40 | .map_err(|err| { 41 | error!( 42 | error = err.to_string(), 43 | "serializing \"user created\" event data to json", 44 | ); 45 | Error::Unknown 46 | })?; 47 | 48 | let connection = self.pool.get().await.map_err(|err| { 49 | error!( 50 | error = err.to_string(), 51 | "pulling connection from rabbitmq pool", 52 | ); 53 | Error::Unknown 54 | })?; 55 | 56 | connection 57 | .create_channel() 58 | .await 59 | .map_err(|err| { 60 | error!(error = err.to_string(), "creating rabbitmq channel",); 61 | Error::Unknown 62 | })? 63 | .basic_publish( 64 | self.exchange, 65 | "", 66 | BasicPublishOptions::default(), 67 | &payload, 68 | BasicProperties::default(), 69 | ) 70 | .await 71 | .map_err(|err| { 72 | error!(error = err.to_string(), "emititng \"user created\" event",); 73 | Error::Unknown 74 | })? 75 | .await 76 | .map_err(|err| { 77 | error!( 78 | error = err.to_string(), 79 | "confirming \"user created\" event reception", 80 | ); 81 | Error::Unknown 82 | })?; 83 | 84 | Ok(()) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/user/grpc.rs: -------------------------------------------------------------------------------- 1 | use super::application::Mailer; 2 | use crate::base64::B64_CUSTOM_ENGINE; 3 | use crate::secret::application::SecretRepository; 4 | use crate::token::application::TokenRepository; 5 | use crate::user::application::{EventBus, UserApplication, UserRepository}; 6 | use crate::{grpc, result::Error}; 7 | use base64::Engine; 8 | use tonic::metadata::errors::InvalidMetadataValue; 9 | use tonic::{Request, Response, Status}; 10 | 11 | const TOTP_ACTION_ENABLE: i32 = 0; 12 | const TOTP_ACTION_DISABLE: i32 = 1; 13 | 14 | // Import the generated rust code into module 15 | mod proto { 16 | tonic::include_proto!("user"); 17 | } 18 | 19 | // Proto generated server traits 20 | use proto::user_server::User; 21 | pub use proto::user_server::UserServer; 22 | 23 | // Proto message structs 24 | use proto::{DeleteRequest, Empty, ResetRequest, SignupRequest, TotpRequest}; 25 | 26 | pub struct UserGrpcService< 27 | U: UserRepository + Sync + Send, 28 | E: SecretRepository + Sync + Send, 29 | S: TokenRepository + Sync + Send, 30 | B: EventBus + Sync + Send, 31 | M: Mailer, 32 | > { 33 | pub user_app: UserApplication<'static, U, E, S, B, M>, 34 | pub jwt_header: &'static str, 35 | pub totp_header: &'static str, 36 | } 37 | 38 | #[tonic::async_trait] 39 | impl< 40 | U: 'static + UserRepository + Sync + Send, 41 | E: 'static + SecretRepository + Sync + Send, 42 | S: 'static + TokenRepository + Sync + Send, 43 | B: 'static + EventBus + Sync + Send, 44 | M: 'static + Mailer + Sync + Send, 45 | > User for UserGrpcService 46 | { 47 | #[instrument(skip(self))] 48 | async fn signup(&self, request: Request) -> Result, Status> { 49 | if request.metadata().get(self.jwt_header).is_some() { 50 | let token = grpc::get_encoded_header(&request, self.jwt_header)?; 51 | let token = self 52 | .user_app 53 | .signup_with_token(&token) 54 | .await 55 | .map(|token| B64_CUSTOM_ENGINE.encode(token)) 56 | .map_err(|err| Status::aborted(err.to_string()))?; 57 | 58 | let mut res = Response::new(Empty {}); 59 | let token = token.parse().map_err(|err: InvalidMetadataValue| { 60 | error!(error = err.to_string(), "parsing token to header"); 61 | Into::::into(Error::Unknown) 62 | })?; 63 | res.metadata_mut().append(self.jwt_header, token); 64 | return Ok(res); 65 | } 66 | 67 | let msg_ref = request.into_inner(); 68 | self.user_app 69 | .verify_signup_email(&msg_ref.email, &msg_ref.pwd) 70 | .await 71 | .map_err(|err| Status::aborted(err.to_string()))?; 72 | 73 | Err(Error::NotAvailable.into()) 74 | } 75 | 76 | #[instrument(skip(self))] 77 | async fn reset(&self, request: Request) -> Result, Status> { 78 | if request.metadata().get(self.jwt_header).is_some() { 79 | let token = grpc::get_encoded_header(&request, self.jwt_header)?; 80 | let msg_ref = request.into_inner(); 81 | return self 82 | .user_app 83 | .reset_with_token(&token, &msg_ref.pwd, &msg_ref.totp) 84 | .await 85 | .map(|_| Response::new(Empty {})) 86 | .map_err(|err| Status::aborted(err.to_string())); 87 | } 88 | 89 | let msg_ref = request.into_inner(); 90 | self.user_app 91 | .verify_reset_email(&msg_ref.email) 92 | .await 93 | .map_err(|err| Status::aborted(err.to_string()))?; 94 | 95 | Err(Error::NotAvailable.into()) 96 | } 97 | 98 | #[instrument(skip(self))] 99 | async fn delete(&self, request: Request) -> Result, Status> { 100 | let token = grpc::get_encoded_header(&request, self.jwt_header)?; 101 | let msg_ref = request.into_inner(); 102 | self.user_app 103 | .delete_with_token(&token, &msg_ref.pwd, &msg_ref.totp) 104 | .await 105 | .map(|_| Response::new(Empty {})) 106 | .map_err(|err| Status::aborted(err.to_string())) 107 | } 108 | 109 | #[instrument(skip(self))] 110 | async fn totp(&self, request: Request) -> Result, Status> { 111 | let token = grpc::get_encoded_header(&request, self.jwt_header)?; 112 | let msg_ref = request.into_inner(); 113 | 114 | if msg_ref.action == TOTP_ACTION_DISABLE { 115 | return self 116 | .user_app 117 | .disable_totp_with_token(&token, &msg_ref.pwd, &msg_ref.totp) 118 | .await 119 | .map(|_| Response::new(Empty {})) 120 | .map_err(|err| Status::unknown(err.to_string())); 121 | } 122 | 123 | if msg_ref.action == TOTP_ACTION_ENABLE { 124 | let token = self 125 | .user_app 126 | .enable_totp_with_token(&token, &msg_ref.pwd, &msg_ref.totp) 127 | .await 128 | .map_err(|err| Status::aborted(err.to_string()))?; 129 | 130 | let mut response = Response::new(Empty {}); 131 | if let Some(token) = token { 132 | let token = token.parse().map_err(|err: InvalidMetadataValue| { 133 | error!(error = err.to_string(), "parsing str to metadata",); 134 | Status::aborted(Error::Unknown.to_string()) 135 | })?; 136 | 137 | response.metadata_mut().insert(self.totp_header, token); 138 | } 139 | } 140 | 141 | Err(Error::NotAvailable.into()) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/user/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod application; 2 | pub mod domain; 3 | #[cfg(feature = "rabbitmq")] 4 | pub mod event_bus; 5 | #[cfg(feature = "grpc")] 6 | pub mod grpc; 7 | #[cfg(feature = "postgres")] 8 | pub mod repository; 9 | -------------------------------------------------------------------------------- /src/user/repository.rs: -------------------------------------------------------------------------------- 1 | use super::{application::UserRepository, domain::User}; 2 | use crate::metadata::application::MetadataRepository; 3 | use crate::result::{Error, Result}; 4 | use async_trait::async_trait; 5 | use sqlx::error::Error as SqlError; 6 | use sqlx::postgres::PgPool; 7 | use std::sync::Arc; 8 | 9 | const QUERY_INSERT_USER: &str = 10 | "INSERT INTO users (name, email, actual_email, password, meta_id) VALUES ($1, $2, $3, $4, $5) RETURNING id"; 11 | const QUERY_FIND_USER: &str = 12 | "SELECT id, name, email, actual_email, password, meta_id FROM users WHERE id = $1"; 13 | const QUERY_FIND_USER_BY_EMAIL: &str = 14 | "SELECT id, name, email, actual_email, password, meta_id FROM users WHERE email = $1 OR actual_email = $1"; 15 | const QUERY_FIND_USER_BY_NAME: &str = 16 | "SELECT id, name, email, actual_email, password, meta_id FROM users WHERE name = $1"; 17 | const QUERY_UPDATE_USER: &str = 18 | "UPDATE users SET name = $1, email = $2, actual_email = $3, password = $4 WHERE id = $5"; 19 | const QUERY_DELETE_USER: &str = "DELETE FROM users WHERE id = $1"; 20 | 21 | type PostgresUserRow = (i32, String, String, String, String, i32); // id, name, email, actual_email, password, meta_id 22 | 23 | pub struct PostgresUserRepository<'a, M: MetadataRepository> { 24 | pub pool: &'a PgPool, 25 | pub metadata_repo: Arc, 26 | } 27 | 28 | impl<'a, M: MetadataRepository> PostgresUserRepository<'a, M> { 29 | async fn build(&self, user_raw: &PostgresUserRow) -> Result { 30 | let meta = self.metadata_repo.find(user_raw.5).await?; 31 | 32 | Ok(User { 33 | id: user_raw.0, 34 | name: user_raw.1.clone(), 35 | email: user_raw.2.clone(), 36 | actual_email: user_raw.3.clone(), 37 | password: user_raw.4.clone(), 38 | meta, 39 | }) 40 | } 41 | } 42 | 43 | #[async_trait] 44 | impl<'a, M: MetadataRepository + std::marker::Sync + std::marker::Send> UserRepository 45 | for PostgresUserRepository<'a, M> 46 | { 47 | async fn find(&self, target: i32) -> Result { 48 | let row: PostgresUserRow = { 49 | // block is required because of connection release 50 | sqlx::query_as(QUERY_FIND_USER) 51 | .bind(target) 52 | .fetch_one(self.pool) 53 | .await 54 | .map_err(|err| { 55 | error!( 56 | error = err.to_string(), 57 | id = target, 58 | "performing select by id query on postgres", 59 | ); 60 | Error::Unknown 61 | })? 62 | }; 63 | 64 | if row.0 == 0 { 65 | return Err(Error::NotFound); 66 | } 67 | 68 | self.build(&row).await // another connection consumed here 69 | } 70 | 71 | async fn find_by_email(&self, target: &str) -> Result { 72 | let row: PostgresUserRow = { 73 | // block is required because of connection release 74 | sqlx::query_as(QUERY_FIND_USER_BY_EMAIL) 75 | .bind(target) 76 | .fetch_one(self.pool) 77 | .await 78 | .map_err(|err| { 79 | if matches!(err, SqlError::RowNotFound) { 80 | return Error::NotFound; 81 | } 82 | 83 | error!( 84 | error = err.to_string(), 85 | email = target, 86 | "performing select by email query on postgres", 87 | ); 88 | Error::Unknown 89 | })? 90 | }; 91 | 92 | if row.0 == 0 { 93 | return Err(Error::NotFound); 94 | } 95 | self.build(&row).await // another connection consumed here 96 | } 97 | 98 | async fn find_by_name(&self, target: &str) -> Result { 99 | let row: PostgresUserRow = { 100 | // block is required because of connection release 101 | sqlx::query_as(QUERY_FIND_USER_BY_NAME) 102 | .bind(target) 103 | .fetch_one(self.pool) 104 | .await 105 | .map_err(|err| { 106 | if matches!(err, SqlError::RowNotFound) { 107 | return Error::NotFound; 108 | } 109 | 110 | error!( 111 | error = err.to_string(), 112 | name = target, 113 | "performing select by name query on postgres", 114 | ); 115 | Error::Unknown 116 | })? 117 | }; 118 | 119 | if row.0 == 0 { 120 | return Err(Error::NotFound); 121 | } 122 | self.build(&row).await // another connection consumed here 123 | } 124 | 125 | async fn create(&self, user: &mut User) -> Result<()> { 126 | self.metadata_repo.create(&mut user.meta).await?; 127 | 128 | let row: (i32,) = sqlx::query_as(QUERY_INSERT_USER) 129 | .bind(&user.name) 130 | .bind(&user.email) 131 | .bind(&user.actual_email) 132 | .bind(&user.password) 133 | .bind(user.meta.get_id()) 134 | .fetch_one(self.pool) 135 | .await 136 | .map_err(|err| { 137 | error!( 138 | error = err.to_string(), 139 | "performing insert query on postgres", 140 | ); 141 | Error::Unknown 142 | })?; 143 | 144 | user.id = row.0; 145 | Ok(()) 146 | } 147 | 148 | async fn save(&self, user: &User) -> Result<()> { 149 | sqlx::query(QUERY_UPDATE_USER) 150 | .bind(&user.name) 151 | .bind(&user.email) 152 | .bind(&user.actual_email) 153 | .bind(&user.password) 154 | .bind(user.id) 155 | .execute(self.pool) 156 | .await 157 | .map_err(|err| { 158 | error!( 159 | error = err.to_string(), 160 | "performing update query on postgres", 161 | ); 162 | Error::Unknown 163 | })?; 164 | 165 | Ok(()) 166 | } 167 | 168 | async fn delete(&self, user: &User) -> Result<()> { 169 | { 170 | // block is required because of connection release 171 | sqlx::query(QUERY_DELETE_USER) 172 | .bind(user.id) 173 | .execute(self.pool) 174 | .await 175 | .map_err(|err| { 176 | error!( 177 | error = err.to_string(), 178 | "performing delete query on postgres", 179 | ); 180 | Error::Unknown 181 | })?; 182 | } 183 | 184 | self.metadata_repo.delete(&user.meta).await?; // another connection consumed here 185 | Ok(()) 186 | } 187 | } 188 | --------------------------------------------------------------------------------