├── .dockerignore ├── .gitignore ├── .travis.yml ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── Rocket.toml ├── build.rs ├── cli ├── Cargo.toml ├── src │ └── main.rs └── test │ └── fixtures │ ├── config_csv.json │ ├── config_ldap.json │ ├── config_mysql.json │ ├── config_noop.json │ ├── config_postgres.json │ ├── config_sqlite.json │ ├── rsa_private_key.der │ ├── rsa_public_key.der │ └── users.csv ├── diesel ├── Cargo.toml ├── docker-compose.db.yml ├── src │ ├── lib.rs │ ├── mysql.rs │ ├── postgres.rs │ ├── schema.rs │ └── sqlite.rs └── test │ └── fixtures │ ├── mysql.sql │ ├── postgres.sql │ └── sqlite.sql ├── docker-compose.yml ├── rust-toolchain ├── src ├── auth │ ├── ldap.rs │ ├── mod.rs │ ├── noop.rs │ ├── simple.rs │ └── util.rs ├── lib.rs ├── macros.rs ├── routes.rs ├── serde_custom │ ├── duration.rs │ └── mod.rs ├── test.rs └── token.rs └── test └── fixtures ├── rsa_private_key.der ├── rsa_private_key.pem ├── rsa_public_key.der ├── rsa_public_key.pem └── users.csv /.dockerignore: -------------------------------------------------------------------------------- 1 | target 2 | config 3 | Dockerfile 4 | docker-compose.yml 5 | docker-compose.override.yml 6 | .gitignore 7 | .dockerignore 8 | README.md 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | config 3 | docker-compose.override.yml 4 | *.rs.fmt 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: rust 3 | rust: 4 | - nightly-2018-11-24 5 | - nightly 6 | matrix: 7 | allow_failures: 8 | - rust: nightly 9 | addons: 10 | apt: 11 | packages: 12 | - libcurl4-openssl-dev 13 | - libelf-dev 14 | - libdw-dev 15 | services: 16 | - docker 17 | before_script: 18 | - sudo /etc/init.d/mysql stop 19 | - sudo /etc/init.d/postgresql stop 20 | - sudo docker-compose -f diesel/docker-compose.db.yml up -d 21 | - | 22 | pip install 'travis-cargo<0.2' --user && 23 | export PATH=$HOME/.local/bin:$PATH 24 | script: 25 | - | 26 | travis-cargo build -- --all-features --all --locked && 27 | travis-cargo test -- --all-features --all && 28 | travis-cargo --only nightly-2018-11-24 doc -- --no-deps --all-features --all 29 | after_script: 30 | - sudo docker-compose -f diesel/docker-compose.db.yml down -v 31 | after_success: 32 | - travis-cargo --only nightly-2018-11-24 doc-upload 33 | env: 34 | global: 35 | - TRAVIS_CARGO_NIGHTLY_FEATURE="" 36 | - secure: hwv0LMn8lmU6kJy3kwumu/V+k0Nh0yJ8/3UFnaYT7WefcXWWl5B8oXem7+SEtSpe6/dfJ+rX1MpwAJhfRlNXSrGc3BZwIvWy6G19AyeXqGLhHpJVXjA3XWXyer72BHuB59FNw0glGVg+p2bl/pOkzUDkQotBoTnMBusSgaTXgPwFOG35KcDf/zfpOd7Cu2mSOLRbQ9KwxhS0v5NyDdpxWrSd1YQYbUUveaBlLKBl87A3ik8StErWopiMUGM4CO4OAR2giUhKkC4fqeodY9+kGwgydkMluKrRTC40xDm25GexDSPlcbs3SiqTd7ezmlk7+N+qU1dclmutkpldr59WZjKiG//CaaPiE7pa+7ferF08p+TyN+W+ce5kqNUNI2bqJy8Pk0Al6YAnwn2SAVZ7WCL8PO/B4NMO7LO5WO6+w9DPSE0cFvyuRCyMHU/q2zTZjB+4A5oTgeZvP3U1TSSPKAslb9LCY7SxwJTin6VS3dfLnVS+dMJe1dpn/Xa7eqp2INcEPKTFMOf63iwP1Go5mvcIvUF4v1JDbAfb1gU5UFwN6an5NAgJUlsfB0Un2K48APyIXrIWcGuHHT9yJV9jpA6TfVkJN7CfTJfPoR0vZbmE9fChZ69JY0HfK+EvVcF3utkIhWXiZ5hjUtcVa2dMVehyRoGCYzimVCWPpllsKSs= 37 | branches: 38 | only: 39 | - master 40 | 41 | notifications: 42 | email: false 43 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rowdy" 3 | version = "0.0.9" 4 | license = "Apache-2.0" 5 | authors = ["Yong Wen Chua "] 6 | build = "build.rs" 7 | description = "`rowdy` is a Rocket based JSON Web token based authentication server." 8 | homepage = "https://github.com/lawliet89/rowdy" 9 | repository = "https://github.com/lawliet89/rowdy" 10 | documentation = "https://docs.rs/rowdy/" 11 | keywords = ["jwt", "rocket", "token", "authentication"] 12 | categories = ["authentication", "web-programming"] 13 | edition = "2018" 14 | 15 | [badges] 16 | travis-ci = { repository = "lawliet89/rowdy" } 17 | 18 | [lib] 19 | name = "rowdy" 20 | path = "src/lib.rs" 21 | test = true 22 | doctest = true 23 | doc = true 24 | 25 | [features] 26 | default = ["simple_authenticator"] 27 | 28 | # A simple file based authenticator 29 | simple_authenticator = ["argon2rs", "csv", "ring"] 30 | # LDAP based authenticator 31 | ldap_authenticator = ["ldap3", "strfmt"] 32 | 33 | [dependencies] 34 | biscuit = "0.1.0" 35 | chrono = { version = "0.4", features = ["serde"] } 36 | hyper = "0.10" 37 | lazy_static = "1.3.0" 38 | log = "0.4" 39 | rocket = "0.4.0" 40 | rocket_cors = "0.4.0" 41 | serde = "1.0" 42 | serde_derive = "1.0" 43 | serde_json = "1.0" 44 | uuid = { version = "0.4", features = ["use_std", "serde"] } 45 | 46 | # Optional dependencies that are activated by the various features 47 | argon2rs = { version = "0.2.5", optional = true } 48 | csv = { version = "1.0.5", optional = true } 49 | ldap3 = { version = "0.6", optional = true } 50 | ring = { version = "0.13.5", optional = true } 51 | strfmt = { version = "0.1.6", optional = true } 52 | 53 | [dev-dependencies] 54 | serde_test = "1.0" 55 | 56 | [build-dependencies] 57 | ansi_term = "0.9" 58 | version_check = "0.1" 59 | 60 | [workspace] 61 | members = ["cli", "diesel"] 62 | 63 | [package.metadata.docs.rs] 64 | all-features = true 65 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM japaric/x86_64-unknown-linux-musl:v0.1.10 as builder 2 | ENV PATH "/root/.cargo/bin:${PATH}" 3 | 4 | ARG RUST_VERSION=nightly-2017-10-11 5 | ARG ARCHITECTURE=x86_64-unknown-linux-musl 6 | RUN set -x \ 7 | && apt-get update \ 8 | && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 9 | build-essential \ 10 | ca-certificates \ 11 | curl \ 12 | libcurl3 \ 13 | git \ 14 | file \ 15 | libssl-dev \ 16 | pkg-config \ 17 | libmysqlclient-dev \ 18 | && curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain ${RUST_VERSION} \ 19 | && rustup target add "${ARCHITECTURE}" \ 20 | && apt-get remove -y --auto-remove curl \ 21 | && apt-get clean \ 22 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 23 | 24 | WORKDIR /app/src 25 | COPY Cargo.toml Cargo.lock ./ 26 | COPY cli/Cargo.toml ./cli/Cargo.toml 27 | COPY diesel/Cargo.toml ./diesel/Cargo.toml 28 | RUN cargo fetch --locked -v 29 | 30 | COPY ./ ./ 31 | RUN cargo build --release --package rowdy-cli --target "${ARCHITECTURE}" -v --frozen 32 | 33 | # Runtime Image 34 | 35 | FROM alpine:3.5 36 | ARG ARCHITECTURE=x86_64-unknown-linux-musl 37 | 38 | # See https://github.com/japaric/cross/issues/119 39 | RUN apk add --update ca-certificates \ 40 | && rm -rf /var/cache/apk/* /tmp/* 41 | ENV SSL_CERT_DIR /etc/ssl/certs 42 | 43 | WORKDIR /app 44 | COPY --from=builder /app/src/target/${ARCHITECTURE}/release/rowdy-cli . 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rowdy 2 | 3 | [![Build Status](https://travis-ci.org/lawliet89/rowdy.svg)](https://travis-ci.org/lawliet89/rowdy) 4 | [![Dependency Status](https://dependencyci.com/github/lawliet89/rowdy/badge)](https://dependencyci.com/github/lawliet89/rowdy) 5 | [![Crates.io](https://img.shields.io/crates/v/rowdy.svg)](https://crates.io/crates/rowdy) 6 | [![Repository](https://img.shields.io/github/tag/lawliet89/rowdy.svg)](https://github.com/lawliet89/rowdy) 7 | [![Documentation](https://docs.rs/rowdy/badge.svg)](https://docs.rs/rowdy) 8 | 9 | Documentation: [Stable](https://docs.rs/rowdy) | [Master](https://lawliet89.github.io/rowdy/) 10 | 11 | `rowdy` is a [Rocket](https://rocket.rs/) based JSON Web token based authentication server 12 | based off Docker Registry's [authentication protocol](https://docs.docker.com/registry/spec/auth/). 13 | 14 | ## Requirements 15 | 16 | Rocket requires nightly Rust. You should probably install Rust with [rustup](https://www.rustup.rs/), then override the code directory to use nightly instead of stable. See 17 | [installation instructions](https://rocket.rs/guide/getting-started/#installing-rust). 18 | 19 | In particular, `rowdy` is currently targetted for `nightly-2017-10-11`. 20 | 21 | ## Testing 22 | 23 | The crate is set up 24 | to make use of [workspaces](http://doc.crates.io/manifest.html#the-workspace--field-optional) for 25 | various parts of `rowdy`. 26 | 27 | To run tests for `rowdy-diesel`, you will need to run the Docker containers for the various 28 | databases. 29 | 30 | ```bash 31 | docker-compose -f diesel/docker-compose.db.yml up 32 | ``` 33 | 34 | To run tests on everything, do `cargo test --all --all-features --no-fail-fast`. 35 | 36 | ## Docker Image 37 | 38 | An musl-linked image can be built from the `Dockerfile` in the repository root. You will need at least Docker 17.05 39 | (API version 1.29) to build. 40 | 41 | By default, the Docker image will not start Rowdy for you. You will need to provide your own configuration file 42 | and command line arguments. The provided `docker-compose.yml` should get you started. 43 | 44 | You can simply define your own `docker-compose.override.yml` file. For example: 45 | 46 | ```yaml 47 | version: "2.1" 48 | services: 49 | rowdy: 50 | environment: 51 | ROCKET_ENV: production 52 | expose: 53 | - "80" 54 | volumes: 55 | - ./config:/app/config 56 | command: [rowdy-cli, csv, config/Config.json] 57 | networks: 58 | nginx: 59 | external: true 60 | ``` 61 | 62 | Then, you can simply start the containers with `docker-compose up --build -d`. 63 | -------------------------------------------------------------------------------- /Rocket.toml: -------------------------------------------------------------------------------- 1 | [development] 2 | address = "localhost" 3 | port = 8000 4 | log = "debug" 5 | 6 | [staging] 7 | address = "0.0.0.0" 8 | port = 80 9 | 10 | [production] 11 | address = "0.0.0.0" 12 | port = 80 13 | 14 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | //! This tiny build script ensures that rowdy is not compiled with an 2 | //! incompatible version of rust. 3 | //! This scipt was stolen from `rocket_codegen`. 4 | 5 | use ansi_term::Color::{Blue, Red, White, Yellow}; 6 | use version_check::{is_min_date, is_min_version, is_nightly}; 7 | 8 | // Specifies the minimum nightly version that is targetted 9 | // Note that sometimes the `rustc` date might be older than the nightly version, 10 | // usually one day older 11 | const MIN_DATE: &'static str = "2018-11-23"; 12 | const MIN_VERSION: &'static str = "1.32.0-nightly"; 13 | 14 | // Convenience macro for writing to stderr. 15 | macro_rules! printerr { 16 | ($($arg:tt)*) => ({ 17 | use std::io::prelude::*; 18 | write!(&mut ::std::io::stderr(), "{}\n", format_args!($($arg)*)) 19 | .expect("Failed to write to stderr.") 20 | }) 21 | } 22 | 23 | fn main() { 24 | let ok_nightly = is_nightly(); 25 | let ok_version = is_min_version(MIN_VERSION); 26 | let ok_date = is_min_date(MIN_DATE); 27 | 28 | let print_version_err = |version: &str, date: &str| { 29 | printerr!( 30 | "{} {}. {} {}.", 31 | White.paint("Installed version is:"), 32 | Yellow.paint(format!("{} ({})", version, date)), 33 | White.paint("Minimum required:"), 34 | Yellow.paint(format!("{} ({})", MIN_VERSION, MIN_DATE)) 35 | ); 36 | }; 37 | 38 | match (ok_nightly, ok_version, ok_date) { 39 | (Some(is_nightly), Some((ok_version, version)), Some((ok_date, date))) => { 40 | if !is_nightly { 41 | printerr!( 42 | "{} {}", 43 | Red.bold().paint("Error:"), 44 | White.paint("rowdy requires a nightly version of Rust.") 45 | ); 46 | print_version_err(&*version, &*date); 47 | printerr!( 48 | "{}{}{}", 49 | Blue.paint("See the README ("), 50 | White.paint("https://github.com/lawliet89/rowdy"), 51 | Blue.paint(") for more information.") 52 | ); 53 | panic!("Aborting compilation due to incompatible compiler.") 54 | } 55 | 56 | if !ok_version || !ok_date { 57 | printerr!( 58 | "{} {}", 59 | Red.bold().paint("Error:"), 60 | White.paint("rowdy requires a more recent version of rustc.") 61 | ); 62 | printerr!( 63 | "{}{}{}", 64 | Blue.paint("Use `"), 65 | White.paint("rustup update"), 66 | Blue.paint("` or your preferred method to update Rust.") 67 | ); 68 | print_version_err(&*version, &*date); 69 | panic!("Aborting compilation due to incompatible compiler.") 70 | } 71 | } 72 | _ => { 73 | println!( 74 | "cargo:warning={}", 75 | "rowdy was unable to check rustc compatibility." 76 | ); 77 | println!( 78 | "cargo:warning={}", 79 | "Build may fail due to incompatible rustc version." 80 | ); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rowdy-cli" 3 | version = "0.0.9" 4 | authors = ["Yong Wen Chua "] 5 | 6 | [[bin]] 7 | name = "rowdy-cli" 8 | path = "src/main.rs" 9 | test = true 10 | doc = false 11 | 12 | [dependencies] 13 | clap = "2.26.2" 14 | rocket = "0.4.0" 15 | rowdy = { path = "../", features = ["simple_authenticator", "ldap_authenticator"] } 16 | rowdy-diesel = { path = "../diesel/", features = ["mysql", "sqlite", "postgres"] } 17 | serde = "1.0" 18 | serde_json = "1.0" 19 | -------------------------------------------------------------------------------- /cli/src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(proc_macro_hygiene, decl_macro)] 2 | 3 | #[macro_use] 4 | extern crate clap; 5 | extern crate rocket; 6 | extern crate rowdy; 7 | extern crate rowdy_diesel; 8 | extern crate serde; 9 | extern crate serde_json; 10 | 11 | use std::fs::File; 12 | use std::io::{self, Read}; 13 | 14 | use clap::{App, AppSettings, Arg, ArgMatches, SubCommand}; 15 | use rowdy::auth; 16 | 17 | fn main() { 18 | let args = make_parser().get_matches(); 19 | let result = run_subcommand(&args); 20 | 21 | std::process::exit(match result { 22 | Ok(()) => 0, 23 | Err(e) => { 24 | println!("Error: {}", e.to_string()); 25 | 1 26 | } 27 | }); 28 | } 29 | 30 | fn run_subcommand(args: &ArgMatches) -> Result<(), rowdy::Error> { 31 | match args.subcommand() { 32 | ("noop", Some(args)) => launch::(args), 33 | ("csv", Some(args)) => launch::(args), 34 | ("ldap", Some(args)) => launch::(args), 35 | ("mysql", Some(args)) => run_diesel::(args), 36 | ("sqlite", Some(args)) => run_diesel::(args), 37 | ("postgres", Some(args)) => { 38 | run_diesel::(args) 39 | } 40 | _ => unreachable!("Unknown subcommand encountered."), 41 | } 42 | } 43 | 44 | /// Make a command line parser for options 45 | fn make_parser<'a, 'b>() -> App<'a, 'b> 46 | where 47 | 'a: 'b, 48 | { 49 | let noop = SubCommand::with_name("noop") 50 | .about("Launch rowdy with a `noop` authenticator.") 51 | .arg( 52 | Arg::with_name("config") 53 | .index(1) 54 | .help( 55 | "Specifies the path to read the configuration from. \ 56 | Use - to refer to STDIN", 57 | ) 58 | .takes_value(true) 59 | .value_name("config_path") 60 | .empty_values(false) 61 | .required(true), 62 | ); 63 | 64 | let csv = SubCommand::with_name("csv") 65 | .about("Launch rowdy with a `csv` authenticator backed by a CSV user database.") 66 | .arg( 67 | Arg::with_name("config") 68 | .index(1) 69 | .help( 70 | "Specifies the path to read the configuration from. \ 71 | Use - to refer to STDIN", 72 | ) 73 | .takes_value(true) 74 | .value_name("config_path") 75 | .empty_values(false) 76 | .required(true), 77 | ); 78 | 79 | let ldap = SubCommand::with_name("ldap") 80 | .about("Launch rowdy with a `ldap` authenticator backed by a LDAP directory.") 81 | .arg( 82 | Arg::with_name("config") 83 | .index(1) 84 | .help( 85 | "Specifies the path to read the configuration from. \ 86 | Use - to refer to STDIN", 87 | ) 88 | .takes_value(true) 89 | .value_name("config_path") 90 | .empty_values(false) 91 | .required(true), 92 | ); 93 | 94 | let mysql = SubCommand::with_name("mysql") 95 | .about("Launch rowdy with a `mysql` authenticator backed by a MySQL table.") 96 | .arg( 97 | Arg::with_name("migrate") 98 | .help( 99 | "Instead of launching the server, perform a migration to create the bare\ 100 | minimum table for Rowdy to work. The migration is idempotent. See \ 101 | https://lawliet89.github.io/rowdy/rowdy_diesel/schema/index.html \ 102 | for schema information", 103 | ) 104 | .long("migrate"), 105 | ) 106 | .arg( 107 | Arg::with_name("config") 108 | .index(1) 109 | .help( 110 | "Specifies the path to read the configuration from. \ 111 | Use - to refer to STDIN", 112 | ) 113 | .takes_value(true) 114 | .value_name("config_path") 115 | .empty_values(false) 116 | .required(true), 117 | ); 118 | 119 | let sqlite = SubCommand::with_name("sqlite") 120 | .about("Launch rowdy with a `sqlite` authenticator backed by a SQLite table.") 121 | .arg( 122 | Arg::with_name("migrate") 123 | .help( 124 | "Instead of launching the server, perform a migration to create the bare\ 125 | minimum table for Rowdy to work. The migration is idempotent. See \ 126 | https://lawliet89.github.io/rowdy/rowdy_diesel/schema/index.html \ 127 | for schema information", 128 | ) 129 | .long("migrate"), 130 | ) 131 | .arg( 132 | Arg::with_name("config") 133 | .index(1) 134 | .help( 135 | "Specifies the path to read the configuration from. \ 136 | Use - to refer to STDIN", 137 | ) 138 | .takes_value(true) 139 | .value_name("config_path") 140 | .empty_values(false) 141 | .required(true), 142 | ); 143 | 144 | let postgres = SubCommand::with_name("postgres") 145 | .about("Launch rowdy with a `postgres` authenticator backed by a PostgresSQL table.") 146 | .arg( 147 | Arg::with_name("migrate") 148 | .help( 149 | "Instead of launching the server, perform a migration to create the bare\ 150 | minimum table for Rowdy to work. The migration is idempotent. See \ 151 | https://lawliet89.github.io/rowdy/rowdy_diesel/schema/index.html \ 152 | for schema information", 153 | ) 154 | .long("migrate"), 155 | ) 156 | .arg( 157 | Arg::with_name("config") 158 | .index(1) 159 | .help( 160 | "Specifies the path to read the configuration from. \ 161 | Use - to refer to STDIN", 162 | ) 163 | .takes_value(true) 164 | .value_name("config_path") 165 | .empty_values(false) 166 | .required(true), 167 | ); 168 | 169 | App::new("rowdy") 170 | .bin_name("rowdy") 171 | .version(crate_version!()) 172 | .author(crate_authors!()) 173 | .setting(AppSettings::SubcommandRequired) 174 | .setting(AppSettings::VersionlessSubcommands) 175 | .global_setting(AppSettings::DontCollapseArgsInUsage) 176 | .global_setting(AppSettings::NextLineHelp) 177 | .about( 178 | r#" 179 | Provide a configuration JSON file to run `rowdy` with. For available fields and examples for the 180 | JSON configuration, refer to the documentation at 181 | https://lawliet89.github.io/rowdy/rowdy/struct.Configuration.html 182 | 183 | You can also, additionally, configure Rocket by using `Rocket.toml` file. 184 | See https://rocket.rs/guide/overview#configuration 185 | 186 | The `noop` subcommand allows all username and passwords to authenticate. 187 | The `csv` subcommand uses a CSV file as its username database. See 188 | https://lawliet89.github.io/rowdy/rowdy/auth/simple/index.html for the database format. 189 | The `mysql`, `postgres`, and `sqlite` subcommands uses a their respective databases for usernames. 190 | 191 | The subcommands will change the format expected by the `basic_authenticator` key of the 192 | configuration JSON. 193 | - noop: The key is expected to be simply an empty map: i.e. `{}` 194 | - csv: The key should behave according to the format documented at 195 | https://lawliet89.github.io/rowdy/rowdy/auth/struct.SimpleAuthenticatorConfiguration.html 196 | - ldap: The key should behave according to the format documented at 197 | https://lawliet89.github.io/rowdy/rowdy/auth/struct.LdapAuthenticator.html 198 | - mysql: The key should behave according to the format documented at 199 | https://lawliet89.github.io/rowdy/rowdy_diesel/mysql/struct.Configuration.html 200 | - sqlite: The key should behave according to the format documented at 201 | https://lawliet89.github.io/rowdy/rowdy_diesel/sqlite/struct.Configuration.html 202 | "#, 203 | ) 204 | .subcommand(noop) 205 | .subcommand(csv) 206 | .subcommand(ldap) 207 | .subcommand(mysql) 208 | .subcommand(sqlite) 209 | .subcommand(postgres) 210 | } 211 | 212 | /// Launch a rocket -- this function will block and never return unless on error 213 | fn launch(args: &ArgMatches) -> Result<(), rowdy::Error> 214 | where 215 | B: auth::AuthenticatorConfiguration, 216 | { 217 | let config = args 218 | .value_of("config") 219 | .expect("Required options to be present"); 220 | 221 | let reader = input_reader(&config)?; 222 | let config = read_config::(reader)?; 223 | let rocket = config.ignite()?; 224 | 225 | // launch() will never return except in error 226 | let launch_error = rocket.mount("/", rowdy::routes()).launch(); 227 | Err(launch_error)? 228 | } 229 | 230 | fn run_diesel( 231 | args: &ArgMatches, 232 | ) -> Result<(), rowdy::Error> 233 | where 234 | Config: auth::AuthenticatorConfiguration, 235 | Auth: auth::Authenticator 236 | + rowdy_diesel::schema::Migration, 237 | Connection: rowdy_diesel::Connection + 'static, 238 | ConnectionPool: std::ops::Deref, 239 | { 240 | let config = args 241 | .value_of("config") 242 | .expect("Required options to be present"); 243 | let reader = input_reader(&config)?; 244 | let config = read_config::(reader)?; 245 | 246 | if args.is_present("migrate") { 247 | println!("Performing migration..."); 248 | let authenticator = config.basic_authenticator.make_authenticator()?; 249 | authenticator.migrate()?; 250 | println!("Migration complete."); 251 | Ok(()) 252 | } else { 253 | let rocket = config.ignite()?; 254 | 255 | // launch() will never return except in error 256 | let launch_error = rocket.mount("/", rowdy::routes()).launch(); 257 | Err(launch_error)? 258 | } 259 | } 260 | 261 | fn read_config(reader: R) -> Result, rowdy::Error> 262 | where 263 | B: auth::AuthenticatorConfiguration, 264 | { 265 | Ok(serde_json::from_reader(reader).map_err(|e| e.to_string())?) 266 | } 267 | 268 | /// Gets a `Read` depending on the path. If the path is `-`, read from STDIN 269 | fn input_reader(path: &str) -> Result, rowdy::Error> { 270 | match path { 271 | "-" => Ok(Box::new(io::stdin())), 272 | path => { 273 | let file = File::open(path)?; 274 | Ok(Box::new(file)) 275 | } 276 | } 277 | } 278 | 279 | #[cfg(test)] 280 | mod tests { 281 | use super::*; 282 | 283 | use std::io::Cursor; 284 | 285 | fn noop_json() -> &'static str { 286 | include_str!("../test/fixtures/config_noop.json") 287 | } 288 | 289 | fn csv_json() -> &'static str { 290 | include_str!("../test/fixtures/config_csv.json") 291 | } 292 | 293 | fn ldap_json() -> &'static str { 294 | include_str!("../test/fixtures/config_ldap.json") 295 | } 296 | 297 | fn mysql_json() -> &'static str { 298 | include_str!("../test/fixtures/config_mysql.json") 299 | } 300 | 301 | fn sqlite_json() -> &'static str { 302 | include_str!("../test/fixtures/config_sqlite.json") 303 | } 304 | 305 | fn postgres_json() -> &'static str { 306 | include_str!("../test/fixtures/config_postgres.json") 307 | } 308 | 309 | fn to_cursor(fixture: F) -> Cursor 310 | where 311 | F: Fn() -> T, 312 | T: AsRef<[u8]>, 313 | { 314 | Cursor::new(fixture()) 315 | } 316 | 317 | #[test] 318 | fn noop_configuration_reading() { 319 | let config = to_cursor(noop_json); 320 | let _ = read_config::(config).expect("to succeed"); 321 | } 322 | 323 | #[test] 324 | fn csv_configuration_reading() { 325 | let config = to_cursor(csv_json); 326 | let _ = 327 | read_config::(config).expect("to succeed"); 328 | } 329 | 330 | #[test] 331 | fn ldap_configuration_reading() { 332 | let config = to_cursor(ldap_json); 333 | let _ = read_config::(config).expect("to succeed"); 334 | } 335 | 336 | #[test] 337 | fn mysql_configuration_reading() { 338 | let config = to_cursor(mysql_json); 339 | let _ = read_config::(config).expect("to succeed"); 340 | } 341 | 342 | #[test] 343 | fn sqlite_configuration_reading() { 344 | let config = to_cursor(sqlite_json); 345 | let _ = read_config::(config).expect("to succeed"); 346 | } 347 | 348 | #[test] 349 | fn postgres_configuration_reading() { 350 | let config = to_cursor(postgres_json); 351 | let _ = 352 | read_config::(config).expect("to succeed"); 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /cli/test/fixtures/config_csv.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": { 3 | "issuer": "https://www.acme.com", 4 | "allowed_origins": { 5 | "Some": [ 6 | "https://www.example.com", 7 | "https://www.foobar.com" 8 | ] 9 | }, 10 | "audience": [ 11 | "https://www.example.com", 12 | "https://www.foobar.com" 13 | ], 14 | "signature_algorithm": "RS256", 15 | "secret": { 16 | "rsa_private": "test/fixtures/rsa_private_key.der", 17 | "rsa_public": "test/fixtures/rsa_public_key.der" 18 | }, 19 | "expiry_duration": 86400 20 | }, 21 | "basic_authenticator": { 22 | "csv_path": "tests/fixtures/users.csv", 23 | "salt": "salty" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /cli/test/fixtures/config_ldap.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": { 3 | "issuer": "https://www.acme.com", 4 | "allowed_origins": { 5 | "Some": [ 6 | "https://www.example.com", 7 | "https://www.foobar.com" 8 | ] 9 | }, 10 | "audience": [ 11 | "https://www.example.com", 12 | "https://www.foobar.com" 13 | ], 14 | "signature_algorithm": "RS256", 15 | "secret": { 16 | "rsa_private": "test/fixtures/rsa_private_key.der", 17 | "rsa_public": "test/fixtures/rsa_public_key.der" 18 | }, 19 | "expiry_duration": 86400 20 | }, 21 | "basic_authenticator": { 22 | "address": "ldap://ldap.forumsys.com", 23 | "bind_dn": "cn=read-only-admin,dc=example,dc=com", 24 | "bind_password": "password", 25 | "search_base": "dc=example,dc=com", 26 | "search_filter": "(uid={account})" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cli/test/fixtures/config_mysql.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": { 3 | "issuer": "https://www.acme.com", 4 | "allowed_origins": { 5 | "Some": [ 6 | "https://www.example.com", 7 | "https://www.foobar.com" 8 | ] 9 | }, 10 | "audience": [ 11 | "https://www.example.com", 12 | "https://www.foobar.com" 13 | ], 14 | "signature_algorithm": "RS256", 15 | "secret": { 16 | "rsa_private": "test/fixtures/rsa_private_key.der", 17 | "rsa_public": "test/fixtures/rsa_public_key.der" 18 | }, 19 | "expiry_duration": 86400 20 | }, 21 | "basic_authenticator": { 22 | "host": "127.0.0.1", 23 | "database": "rowdy", 24 | "user": "root", 25 | "password": "" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cli/test/fixtures/config_noop.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": { 3 | "issuer": "https://www.acme.com", 4 | "allowed_origins": { 5 | "Some": [ 6 | "https://www.example.com", 7 | "https://www.foobar.com" 8 | ] 9 | }, 10 | "audience": [ 11 | "https://www.example.com", 12 | "https://www.foobar.com" 13 | ], 14 | "signature_algorithm": "RS256", 15 | "secret": { 16 | "rsa_private": "test/fixtures/rsa_private_key.der", 17 | "rsa_public": "test/fixtures/rsa_public_key.der" 18 | }, 19 | "expiry_duration": 86400 20 | }, 21 | "basic_authenticator": {}, 22 | "refresh_token": { 23 | "cek_algorithm": "A256GCMKW", 24 | "enc_algorithm": "A256", 25 | "key": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 26 | "expiry_duration": 864000 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /cli/test/fixtures/config_postgres.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": { 3 | "issuer": "https://www.acme.com", 4 | "allowed_origins": { 5 | "Some": [ 6 | "https://www.example.com", 7 | "https://www.foobar.com" 8 | ] 9 | }, 10 | "audience": [ 11 | "https://www.example.com", 12 | "https://www.foobar.com" 13 | ], 14 | "signature_algorithm": "RS256", 15 | "secret": { 16 | "rsa_private": "test/fixtures/rsa_private_key.der", 17 | "rsa_public": "test/fixtures/rsa_public_key.der" 18 | }, 19 | "expiry_duration": 86400 20 | }, 21 | "basic_authenticator": { 22 | "host": "127.0.0.1", 23 | "database": "rowdy", 24 | "user": "postgres", 25 | "password": "postgres" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cli/test/fixtures/config_sqlite.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": { 3 | "issuer": "https://www.acme.com", 4 | "allowed_origins": { 5 | "Some": [ 6 | "https://www.example.com", 7 | "https://www.foobar.com" 8 | ] 9 | }, 10 | "audience": [ 11 | "https://www.example.com", 12 | "https://www.foobar.com" 13 | ], 14 | "signature_algorithm": "RS256", 15 | "secret": { 16 | "rsa_private": "test/fixtures/rsa_private_key.der", 17 | "rsa_public": "test/fixtures/rsa_public_key.der" 18 | }, 19 | "expiry_duration": 86400 20 | }, 21 | "basic_authenticator": { 22 | "path": "../target/sqlite.db" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cli/test/fixtures/rsa_private_key.der: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawliet89/rowdy/44cf405ad9330ae073889bd043c4e0f02c57e290/cli/test/fixtures/rsa_private_key.der -------------------------------------------------------------------------------- /cli/test/fixtures/rsa_public_key.der: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawliet89/rowdy/44cf405ad9330ae073889bd043c4e0f02c57e290/cli/test/fixtures/rsa_public_key.der -------------------------------------------------------------------------------- /cli/test/fixtures/users.csv: -------------------------------------------------------------------------------- 1 | mei,aac846b3ef07dc88f417cc73775e32724580c17b2068c11b722e9dc6a220c0e8,37a82d20d2f53963b1ac7934e9fc9b80c5778bc51bd57ccb33543d2da0d25069 2 | foobar,615585bfbdd7c762174fff0b026881900c29828f504df7f87b213872b057b8dc,25c9fee3f2cf30e278aaf8b2b42f18a73dd39b77cfd08bedbe93d9ba3c90befa 3 | -------------------------------------------------------------------------------- /diesel/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rowdy-diesel" 3 | version = "0.0.9" 4 | authors = ["Yong Wen Chua "] 5 | 6 | [lib] 7 | name = "rowdy_diesel" 8 | path = "src/lib.rs" 9 | test = true 10 | doctest = true 11 | doc = true 12 | 13 | [features] 14 | default = [] 15 | 16 | # Support MySQL 17 | mysql = ["diesel/mysql"] 18 | 19 | # Support Sqlite 20 | sqlite = ["diesel/sqlite"] 21 | 22 | # Support Postgres 23 | postgres = ["diesel/postgres"] 24 | 25 | [dependencies] 26 | diesel = { version = "1.4.1", features = ["r2d2"] } 27 | log = "0.4" 28 | r2d2 = "0.8" 29 | ring = "0.13.5" 30 | rocket = "0.4.0" 31 | rowdy = { path = "../" } 32 | serde = "1.0" 33 | serde_derive = "1.0" 34 | serde_json = "1.0" 35 | 36 | [package.metadata.docs.rs] 37 | all-features = true 38 | -------------------------------------------------------------------------------- /diesel/docker-compose.db.yml: -------------------------------------------------------------------------------- 1 | # Test DB services needed for `rowdy-diesel` tests 2 | version: "2.0" 3 | services: 4 | mysql: 5 | image: mysql:5 6 | environment: 7 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes" 8 | MYSQL_DATABASE: "rowdy" 9 | ports: 10 | - 127.0.0.1:3306:3306 11 | postgres: 12 | image: postgres:10 13 | environment: 14 | POSTGRES_USER: postgres 15 | POSTGRES_PASSWORD: postgres 16 | POSTGRES_DB: rowdy 17 | ports: 18 | - 127.0.0.1:5432:5432 19 | -------------------------------------------------------------------------------- /diesel/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Diesel Support for Rowdy 2 | //! 3 | //! Allows you to use a Database table as the authentication soruce for Rowdy via the 4 | //! the [diesel](https://diesel.rs) ORM. 5 | //! 6 | //! ## Features 7 | //! By default, this crate does not have any default feature enabled. To support one or more 8 | //! of the databases supported by diesel, you will have to enable the following feature flags: 9 | //! 10 | //! - `mysql` 11 | //! - `postgres` 12 | //! - `sqlite` 13 | //! 14 | //! For example, 15 | //! 16 | //! ```toml 17 | //! [dependencies] 18 | //! rowdy_diesel = { version = "0.0.1", features = ["mysql"] } 19 | //! ``` 20 | 21 | #![allow( 22 | legacy_directory_ownership, 23 | missing_copy_implementations, 24 | missing_debug_implementations, 25 | unknown_lints, 26 | intra_doc_link_resolution_failure 27 | )] 28 | #![deny( 29 | const_err, 30 | dead_code, 31 | deprecated, 32 | exceeding_bitshifts, 33 | improper_ctypes, 34 | missing_docs, 35 | mutable_transmutes, 36 | no_mangle_const_items, 37 | non_camel_case_types, 38 | non_shorthand_field_patterns, 39 | non_upper_case_globals, 40 | overflowing_literals, 41 | path_statements, 42 | plugin_as_library, 43 | stable_features, 44 | trivial_casts, 45 | trivial_numeric_casts, 46 | unconditional_recursion, 47 | unknown_crate_types, 48 | unreachable_code, 49 | unused_allocation, 50 | unused_assignments, 51 | unused_attributes, 52 | unused_comparisons, 53 | unused_extern_crates, 54 | unused_features, 55 | unused_imports, 56 | unused_import_braces, 57 | unused_qualifications, 58 | unused_must_use, 59 | unused_mut, 60 | unused_parens, 61 | unused_results, 62 | unused_unsafe, 63 | unused_variables, 64 | variant_size_differences, 65 | warnings, 66 | while_true 67 | )] 68 | #![doc(test(attr(allow(unused_variables), deny(warnings))))] 69 | 70 | #[macro_use] 71 | extern crate diesel; 72 | #[macro_use] 73 | extern crate log; 74 | extern crate ring; 75 | #[macro_use] 76 | extern crate rocket; 77 | extern crate rowdy; 78 | // we are using the "log_!" macros which are redefined from `log`'s 79 | #[macro_use] 80 | extern crate serde_derive; 81 | extern crate serde_json; 82 | 83 | use diesel::r2d2::{ConnectionManager, Pool, PooledConnection}; 84 | use serde_json::value; 85 | // FIXME: Remove dependency on `ring`. 86 | use ring::constant_time::verify_slices_are_equal; 87 | use rowdy::auth::util::{hash_password_digest, hex_dump}; 88 | use rowdy::auth::{self, AuthenticationResult, Authorization, Basic}; 89 | use rowdy::{JsonMap, JsonValue}; 90 | 91 | pub mod schema; 92 | 93 | #[cfg(feature = "mysql")] 94 | pub mod mysql; 95 | 96 | #[cfg(feature = "sqlite")] 97 | pub mod sqlite; 98 | 99 | #[cfg(feature = "postgres")] 100 | pub mod postgres; 101 | 102 | pub use diesel::connection::Connection; 103 | /// A connection pool for the Diesel backed authenticators 104 | /// 105 | /// Type `T` should implement 106 | /// [`Connection`](http://docs.diesel.rs/diesel/connection/trait.Connection.html) 107 | pub(crate) type ConnectionPool = Pool>; 108 | 109 | /// Errors from using `rowdy-diesel`. 110 | /// 111 | /// This enum `impl From for rowdy::Error`, and can be used with the `?` operator 112 | /// in places where `rowdy::Error` is expected. 113 | #[derive(Debug)] 114 | pub enum Error { 115 | /// A diesel connection error 116 | ConnectionError(diesel::result::ConnectionError), 117 | /// A generic error occuring from Diesel 118 | DieselError(diesel::result::Error), 119 | /// Authentication error 120 | AuthenticationFailure, 121 | /// Invalid Unicode characters in path 122 | InvalidUnicodeInPath, 123 | /// Connection Pool Error 124 | PoolError(r2d2::Error), 125 | } 126 | 127 | impl From for Error { 128 | fn from(error: diesel::result::ConnectionError) -> Error { 129 | Error::ConnectionError(error) 130 | } 131 | } 132 | 133 | impl From for Error { 134 | fn from(error: diesel::result::Error) -> Error { 135 | Error::DieselError(error) 136 | } 137 | } 138 | 139 | impl From for Error { 140 | fn from(error: r2d2::Error) -> Error { 141 | Error::PoolError(error) 142 | } 143 | } 144 | 145 | impl From for rowdy::Error { 146 | fn from(error: Error) -> rowdy::Error { 147 | match error { 148 | Error::ConnectionError(e) => { 149 | rowdy::Error::Auth(rowdy::auth::Error::GenericError(e.to_string())) 150 | } 151 | Error::DieselError(e) => { 152 | rowdy::Error::Auth(rowdy::auth::Error::GenericError(e.to_string())) 153 | } 154 | Error::InvalidUnicodeInPath => rowdy::Error::Auth(rowdy::auth::Error::GenericError( 155 | "Path contains invalid unicode characters".to_string(), 156 | )), 157 | Error::AuthenticationFailure => { 158 | rowdy::Error::Auth(rowdy::auth::Error::AuthenticationFailure) 159 | } 160 | Error::PoolError(e) => { 161 | rowdy::Error::Auth(rowdy::auth::Error::GenericError(e.to_string())) 162 | } 163 | } 164 | } 165 | } 166 | 167 | /// A user record in the database 168 | #[derive(Queryable, Serialize, Deserialize)] 169 | pub(crate) struct User { 170 | username: String, 171 | hash: Vec, 172 | salt: Vec, 173 | } 174 | 175 | /// A generic authenticator backed by a connection to a database via [diesel](http://diesel.rs/). 176 | /// 177 | /// Instead of using this, you should use the "specialised" authenticators defined in the 178 | /// `mysql`, `pg`, or `sqlite` modules for your database. 179 | /// 180 | /// Passwords are hasahed with `argon2i`, in addition to a salt. 181 | pub struct Authenticator 182 | where 183 | T: Connection + 'static, 184 | { 185 | pool: ConnectionPool, 186 | } 187 | 188 | impl Authenticator 189 | where 190 | T: Connection + 'static, 191 | String: diesel::types::FromSql::Backend>, 192 | Vec: diesel::types::FromSql::Backend>, 193 | { 194 | /// Creates an authenticator backed by a table in a database using a connection pool. 195 | #[allow(dead_code)] 196 | pub(crate) fn new(pool: ConnectionPool) -> Self { 197 | Self { pool } 198 | } 199 | 200 | /// Retrieve a connection to the database from the pool 201 | pub(crate) fn get_pooled_connection( 202 | &self, 203 | ) -> Result>, Error> { 204 | debug_!("Retrieving a connection from the pool"); 205 | Ok(self.pool.get()?) 206 | } 207 | 208 | /// Search for the specified user entry 209 | fn search(&self, connection: &T, search_user: &str) -> Result, Error> { 210 | use diesel::prelude::*; 211 | use diesel::ExpressionMethods; 212 | use schema::users::dsl::*; 213 | 214 | debug_!("Querying user {} from database", search_user); 215 | let results = users 216 | .filter(username.eq(search_user)) 217 | .load::(connection)?; 218 | Ok(results) 219 | } 220 | 221 | /// Hash a password with the salt. See struct level documentation for the algorithm used. 222 | // TODO: Write an "example" tool to salt easily 223 | pub fn hash_password(password: &str, salt: &[u8]) -> Result { 224 | Ok(hex_dump(hash_password_digest(password, salt).as_ref())) 225 | } 226 | 227 | /// Serialize a user as payload for a refresh token 228 | fn serialize_refresh_token_payload(user: &User) -> Result { 229 | let user = value::to_value(user).map_err(|_| Error::AuthenticationFailure)?; 230 | let mut map = JsonMap::with_capacity(1); 231 | let _ = map.insert("user".to_string(), user); 232 | Ok(JsonValue::Object(map)) 233 | } 234 | 235 | /// Deserialize a user from a refresh token payload 236 | fn deserialize_refresh_token_payload(refresh_payload: JsonValue) -> Result { 237 | match refresh_payload { 238 | JsonValue::Object(ref map) => { 239 | let user = map 240 | .get("user") 241 | .ok_or_else(|| Error::AuthenticationFailure)?; 242 | // TODO verify the user object matches the database 243 | Ok(value::from_value(user.clone()).map_err(|_| Error::AuthenticationFailure)?) 244 | } 245 | _ => Err(Error::AuthenticationFailure), 246 | } 247 | } 248 | 249 | /// Build an `AuthenticationResult` for a `User` 250 | fn build_authentication_result( 251 | user: &User, 252 | include_refresh_payload: bool, 253 | ) -> Result { 254 | let refresh_payload = if include_refresh_payload { 255 | Some(Self::serialize_refresh_token_payload(user)?) 256 | } else { 257 | None 258 | }; 259 | 260 | // TODO implement private claims in DB 261 | let private_claims = JsonValue::Object(JsonMap::new()); 262 | 263 | Ok(AuthenticationResult { 264 | subject: user.username.clone(), 265 | private_claims, 266 | refresh_payload, 267 | }) 268 | } 269 | 270 | /// Verify that some user with the provided password exists in the database, and the password 271 | /// is correct. 272 | /// 273 | /// Returns the payload to be included in a refresh token if successful 274 | pub fn verify( 275 | &self, 276 | username: &str, 277 | password: &str, 278 | include_refresh_payload: bool, 279 | ) -> Result { 280 | let user = { 281 | let connection = self.get_pooled_connection()?; 282 | let mut user = self.search(&connection, username).map_err(|e| { 283 | error_!("Error searching database: {:?}", e); 284 | Error::AuthenticationFailure 285 | })?; 286 | 287 | if user.len() != 1 { 288 | error_!("{} users with username {} found.", user.len(), username); 289 | Err(Error::AuthenticationFailure)?; 290 | } 291 | 292 | user.pop().expect("at least one user to be found.") // safe to unwrap 293 | }; 294 | assert_eq!(username, user.username); 295 | 296 | let actual_password_digest = hash_password_digest(password, &user.salt); 297 | if !verify_slices_are_equal(actual_password_digest.as_ref(), &user.hash).is_ok() { 298 | error_!("Password hash verification failed"); 299 | Err(Error::AuthenticationFailure) 300 | } else { 301 | Self::build_authentication_result(&user, include_refresh_payload) 302 | } 303 | } 304 | } 305 | 306 | impl auth::Authenticator for Authenticator 307 | where 308 | T: Connection + 'static, 309 | String: diesel::types::FromSql::Backend>, 310 | Vec: diesel::types::FromSql::Backend>, 311 | { 312 | fn authenticate( 313 | &self, 314 | authorization: &Authorization, 315 | include_refresh_payload: bool, 316 | ) -> Result { 317 | let username = authorization.username(); 318 | let password = authorization.password().unwrap_or_else(|| "".to_string()); 319 | Ok(self.verify(&username, &password, include_refresh_payload)?) 320 | } 321 | 322 | fn authenticate_refresh_token( 323 | &self, 324 | refresh_payload: &JsonValue, 325 | ) -> Result { 326 | let user = Self::deserialize_refresh_token_payload(refresh_payload.clone())?; 327 | Ok(Self::build_authentication_result(&user, false)?) 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /diesel/src/mysql.rs: -------------------------------------------------------------------------------- 1 | //! MySQL authenticator module 2 | //! 3 | //! Requires `features = ["mysql"]` in your `Cargo.toml` 4 | use diesel::mysql::MysqlConnection; 5 | use diesel::prelude::*; 6 | use diesel::r2d2::{Builder, ConnectionManager}; 7 | 8 | use rowdy; 9 | use rowdy::auth::{AuthenticatorConfiguration, Basic}; 10 | 11 | use schema; 12 | use {Error, PooledConnection}; 13 | 14 | /// A rowdy authenticator that uses a MySQL backed database to provide the users 15 | pub type Authenticator = ::Authenticator; 16 | 17 | impl Authenticator { 18 | /// Using a database connection string of the form 19 | /// `mysql://[user[:password]@]host/database_name`, 20 | /// create an authenticator that is backed by a connection pool to a MySQL database 21 | pub fn with_uri(uri: &str) -> Result { 22 | // Attempt a test connection with diesel 23 | let _ = Self::connect(uri)?; 24 | 25 | debug_!("Creating a connection pool"); 26 | let manager = ConnectionManager::new(uri.as_ref()); 27 | let pool = Builder::new().build(manager)?; 28 | Ok(Self::new(pool)) 29 | } 30 | 31 | /// Create a new `Authenticator` with a database config 32 | /// 33 | pub fn with_configuration( 34 | host: &str, 35 | port: u16, 36 | database: &str, 37 | user: &str, 38 | pass: &str, 39 | ) -> Result { 40 | let database_uri = format!("mysql://{}:{}@{}:{}/{}", user, pass, host, port, database); 41 | Self::with_uri(&database_uri) 42 | } 43 | 44 | /// Test connection with the database uri 45 | fn connect(uri: &str) -> Result { 46 | debug_!("Attempting a connection to MySQL database"); 47 | Ok(MysqlConnection::establish(uri)?) 48 | } 49 | } 50 | 51 | impl schema::Migration for Authenticator { 52 | type Connection = PooledConnection>; 53 | 54 | fn connection(&self) -> Result { 55 | self.get_pooled_connection() 56 | } 57 | 58 | fn migration_query(&self) -> &str { 59 | r#"CREATE TABLE IF NOT EXISTS `users` ( 60 | `username` VARCHAR(255) UNIQUE NOT NULL, 61 | `hash` BINARY(32) NOT NULL, 62 | `salt` VARBINARY(255) NOT NULL, 63 | PRIMARY KEY (`username`) 64 | );"# 65 | } 66 | } 67 | 68 | /// (De)Serializable configuration for MySQL Authenticator. This struct should be included 69 | /// in the base `Configuration`. 70 | /// # Examples 71 | /// ```json 72 | /// { 73 | /// "host": "localhost", 74 | /// "port": 3306, 75 | /// "database": "auth_users", 76 | /// "user": "auth_user", 77 | /// "password": "password" 78 | /// } 79 | /// ``` 80 | #[derive(Eq, PartialEq, Serialize, Deserialize, Debug)] 81 | pub struct Configuration { 82 | /// Host for the MySql database manager - domain name or IP 83 | pub host: String, 84 | /// MySql database port - default 3306 85 | #[serde(default = "default_port")] 86 | pub port: u16, 87 | /// MySql database 88 | pub database: String, 89 | /// MySql user 90 | pub user: String, 91 | /// MySql password 92 | pub password: String, 93 | } 94 | 95 | fn default_port() -> u16 { 96 | 3306 97 | } 98 | 99 | impl AuthenticatorConfiguration for Configuration { 100 | type Authenticator = Authenticator; 101 | 102 | fn make_authenticator(&self) -> Result { 103 | Ok(Authenticator::with_configuration( 104 | &self.host, 105 | self.port, 106 | &self.database, 107 | &self.user, 108 | &self.password, 109 | )?) 110 | } 111 | } 112 | 113 | #[cfg(test)] 114 | mod tests { 115 | use std::sync::{Once, ONCE_INIT}; 116 | 117 | use diesel::connection::SimpleConnection; 118 | use rowdy::auth::Authenticator; 119 | 120 | use super::*; 121 | use schema::Migration; 122 | 123 | static SEED: Once = ONCE_INIT; 124 | 125 | /// Reset and seed the databse. This should only be run once. 126 | fn reset_and_seed(authenticator: &super::Authenticator) { 127 | SEED.call_once(|| { 128 | let query = format!( 129 | include_str!("../test/fixtures/mysql.sql"), 130 | migration = authenticator.migration_query() 131 | ); 132 | 133 | let connection = authenticator.get_pooled_connection().expect("to succeed"); 134 | connection.batch_execute(&query).expect("to work"); 135 | }); 136 | } 137 | 138 | fn make_authenticator() -> super::Authenticator { 139 | let authenticator = 140 | super::Authenticator::with_configuration("127.0.0.1", 3306, "rowdy", "root", "") 141 | .expect("To be constructed successfully"); 142 | reset_and_seed(&authenticator); 143 | authenticator 144 | } 145 | 146 | #[test] 147 | fn hashing_is_done_correctly() { 148 | let hashed_password = super::Authenticator::hash_password("password", &[0; 32]) 149 | .expect("to hash successfully"); 150 | assert_eq!( 151 | "e6e1111452a5574d8d64f6f4ba6fabc86af5c45c341df1eb23026373c41d24b8", 152 | hashed_password 153 | ); 154 | } 155 | 156 | #[test] 157 | fn hashing_is_done_correctly_for_unicode() { 158 | let hashed_password = super::Authenticator::hash_password("冻住,不许走!", &[0; 32]) 159 | .expect("to hash successfully"); 160 | assert_eq!( 161 | "b400a5eea452afcc67a81602f28012e5634404ddf1e043d6ff1df67022c88cd2", 162 | hashed_password 163 | ); 164 | } 165 | 166 | /// Migration should be idempotent 167 | #[test] 168 | fn migration_is_idempotent() { 169 | let authenticator = make_authenticator(); 170 | authenticator 171 | .migrate() 172 | .expect("To succeed and be idempotent") 173 | } 174 | 175 | #[test] 176 | fn authentication_with_username_and_password() { 177 | let authenticator = make_authenticator(); 178 | 179 | let _ = authenticator 180 | .verify("foobar", "password", false) 181 | .expect("To verify correctly"); 182 | 183 | let result = authenticator 184 | .verify("mei", "冻住,不许走!", false) 185 | .expect("to be verified"); 186 | 187 | // refresh refresh_payload is not provided when not requested 188 | assert!(result.refresh_payload.is_none()); 189 | } 190 | 191 | #[test] 192 | fn authentication_with_refresh_payload() { 193 | let authenticator = make_authenticator(); 194 | 195 | let result = authenticator 196 | .verify("foobar", "password", true) 197 | .expect("To verify correctly"); 198 | // refresh refresh_payload is provided when requested 199 | assert!(result.refresh_payload.is_some()); 200 | 201 | let result = authenticator 202 | .authenticate_refresh_token(result.refresh_payload.as_ref().unwrap()) 203 | .expect("to be successful"); 204 | assert!(result.refresh_payload.is_none()); 205 | } 206 | 207 | #[test] 208 | fn mysql_authenticator_configuration_deserialization() { 209 | use rowdy::auth::AuthenticatorConfiguration; 210 | use serde_json; 211 | 212 | let json = r#"{ 213 | "host": "127.0.0.1", 214 | "port": 3306, 215 | "database": "rowdy", 216 | "user": "root", 217 | "password": "" 218 | }"#; 219 | 220 | let deserialized: Configuration = 221 | serde_json::from_str(json).expect("to deserialize successfully"); 222 | let expected_config = Configuration { 223 | host: "127.0.0.1".to_string(), 224 | port: 3306, 225 | database: "rowdy".to_string(), 226 | user: "root".to_string(), 227 | password: "".to_string(), 228 | }; 229 | assert_eq!(deserialized, expected_config); 230 | 231 | let _ = expected_config 232 | .make_authenticator() 233 | .expect("to be constructed correctly"); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /diesel/src/postgres.rs: -------------------------------------------------------------------------------- 1 | //! PostgresSQL authenticator module 2 | //! 3 | //! Requires `features = ["postgres"]` in your `Cargo.toml` 4 | use diesel::pg::PgConnection; 5 | use diesel::prelude::*; 6 | use diesel::r2d2::{Builder, ConnectionManager}; 7 | 8 | use rowdy; 9 | use rowdy::auth::{AuthenticatorConfiguration, Basic}; 10 | 11 | use schema; 12 | use {Error, PooledConnection}; 13 | 14 | /// A rowdy authenticator that uses a PostgresSQL backed database to provide the users 15 | pub type Authenticator = ::Authenticator; 16 | 17 | impl Authenticator { 18 | /// Using a database connection string of the form 19 | /// `postgresql://[user[:password]@][host][:port][/database_name]`, 20 | /// create an authenticator that is backed by a connection pool to a PostgresSQL database 21 | pub fn with_uri(uri: &str) -> Result { 22 | // Attempt a test connection with diesel 23 | let _ = Self::connect(uri)?; 24 | 25 | debug_!("Creating a connection pool"); 26 | let manager = ConnectionManager::new(uri.as_ref()); 27 | let pool = Builder::new().build(manager)?; 28 | Ok(Self::new(pool)) 29 | } 30 | 31 | /// Create a new `Authenticator` with a database config 32 | /// 33 | pub fn with_configuration( 34 | host: &str, 35 | port: u16, 36 | database: &str, 37 | user: &str, 38 | pass: &str, 39 | ) -> Result { 40 | let database_uri = format!( 41 | "postgresql://{}:{}@{}:{}/{}", 42 | user, pass, host, port, database 43 | ); 44 | Self::with_uri(&database_uri) 45 | } 46 | 47 | /// Test connection with the database uri 48 | fn connect(uri: &str) -> Result { 49 | debug_!("Attempting a connection to MySQL database"); 50 | Ok(PgConnection::establish(uri)?) 51 | } 52 | } 53 | 54 | impl schema::Migration for Authenticator { 55 | type Connection = PooledConnection>; 56 | 57 | fn connection(&self) -> Result { 58 | self.get_pooled_connection() 59 | } 60 | 61 | fn migration_query(&self) -> &str { 62 | r#"CREATE TABLE IF NOT EXISTS users ( 63 | username VARCHAR(255) UNIQUE NOT NULL, 64 | hash BYTEA NOT NULL, 65 | salt BYTEA NOT NULL, 66 | PRIMARY KEY (username) 67 | );"# 68 | } 69 | } 70 | 71 | /// (De)Serializable configuration for PostgresSQL Authenticator. This struct should be included 72 | /// in the base `Configuration`. 73 | /// # Examples 74 | /// ```json 75 | /// { 76 | /// "host": "localhost", 77 | /// "port": 3306, 78 | /// "database": "auth_users", 79 | /// "user": "auth_user", 80 | /// "password": "password" 81 | /// } 82 | /// ``` 83 | #[derive(Eq, PartialEq, Serialize, Deserialize, Debug)] 84 | pub struct Configuration { 85 | /// Host for the PostgresSQL database manager - domain name or IP 86 | pub host: String, 87 | /// PostgresSQL database port - default 5432 88 | #[serde(default = "default_port")] 89 | pub port: u16, 90 | /// PostgresSQL database 91 | pub database: String, 92 | /// PostgresSQL user 93 | pub user: String, 94 | /// PostgresSQL password 95 | pub password: String, 96 | } 97 | 98 | fn default_port() -> u16 { 99 | 5432 100 | } 101 | 102 | impl AuthenticatorConfiguration for Configuration { 103 | type Authenticator = Authenticator; 104 | 105 | fn make_authenticator(&self) -> Result { 106 | Ok(Authenticator::with_configuration( 107 | &self.host, 108 | self.port, 109 | &self.database, 110 | &self.user, 111 | &self.password, 112 | )?) 113 | } 114 | } 115 | 116 | #[cfg(test)] 117 | mod tests { 118 | use std::sync::{Once, ONCE_INIT}; 119 | 120 | use diesel::connection::SimpleConnection; 121 | use rowdy::auth::Authenticator; 122 | 123 | use super::*; 124 | use schema::Migration; 125 | 126 | static SEED: Once = ONCE_INIT; 127 | 128 | /// Reset and seed the databse. This should only be run once. 129 | fn reset_and_seed(authenticator: &super::Authenticator) { 130 | SEED.call_once(|| { 131 | let query = format!( 132 | include_str!("../test/fixtures/postgres.sql"), 133 | migration = authenticator.migration_query() 134 | ); 135 | 136 | let connection = authenticator.get_pooled_connection().expect("to succeed"); 137 | connection.batch_execute(&query).expect("to work"); 138 | }); 139 | } 140 | 141 | fn make_authenticator() -> super::Authenticator { 142 | let authenticator = super::Authenticator::with_configuration( 143 | "127.0.0.1", 144 | 5432, 145 | "rowdy", 146 | "postgres", 147 | "postgres", 148 | ) 149 | .expect("To be constructed successfully"); 150 | reset_and_seed(&authenticator); 151 | authenticator 152 | } 153 | 154 | #[test] 155 | fn hashing_is_done_correctly() { 156 | let hashed_password = super::Authenticator::hash_password("password", &[0; 32]) 157 | .expect("to hash successfully"); 158 | assert_eq!( 159 | "e6e1111452a5574d8d64f6f4ba6fabc86af5c45c341df1eb23026373c41d24b8", 160 | hashed_password 161 | ); 162 | } 163 | 164 | #[test] 165 | fn hashing_is_done_correctly_for_unicode() { 166 | let hashed_password = super::Authenticator::hash_password("冻住,不许走!", &[0; 32]) 167 | .expect("to hash successfully"); 168 | assert_eq!( 169 | "b400a5eea452afcc67a81602f28012e5634404ddf1e043d6ff1df67022c88cd2", 170 | hashed_password 171 | ); 172 | } 173 | 174 | /// Migration should be idempotent 175 | #[test] 176 | fn migration_is_idempotent() { 177 | let authenticator = make_authenticator(); 178 | authenticator 179 | .migrate() 180 | .expect("To succeed and be idempotent") 181 | } 182 | 183 | #[test] 184 | fn authentication_with_username_and_password() { 185 | let authenticator = make_authenticator(); 186 | 187 | let _ = authenticator 188 | .verify("foobar", "password", false) 189 | .expect("To verify correctly"); 190 | 191 | let result = authenticator 192 | .verify("mei", "冻住,不许走!", false) 193 | .expect("to be verified"); 194 | 195 | // refresh refresh_payload is not provided when not requested 196 | assert!(result.refresh_payload.is_none()); 197 | } 198 | 199 | #[test] 200 | fn authentication_with_refresh_payload() { 201 | let authenticator = make_authenticator(); 202 | 203 | let result = authenticator 204 | .verify("foobar", "password", true) 205 | .expect("To verify correctly"); 206 | // refresh refresh_payload is provided when requested 207 | assert!(result.refresh_payload.is_some()); 208 | 209 | let result = authenticator 210 | .authenticate_refresh_token(result.refresh_payload.as_ref().unwrap()) 211 | .expect("to be successful"); 212 | assert!(result.refresh_payload.is_none()); 213 | } 214 | 215 | #[test] 216 | fn mysql_authenticator_configuration_deserialization() { 217 | use rowdy::auth::AuthenticatorConfiguration; 218 | use serde_json; 219 | 220 | let json = r#"{ 221 | "host": "127.0.0.1", 222 | "port": 5432, 223 | "database": "rowdy", 224 | "user": "postgres", 225 | "password": "postgres" 226 | }"#; 227 | 228 | let deserialized: Configuration = 229 | serde_json::from_str(json).expect("to deserialize successfully"); 230 | let expected_config = Configuration { 231 | host: "127.0.0.1".to_string(), 232 | port: 5432, 233 | database: "rowdy".to_string(), 234 | user: "postgres".to_string(), 235 | password: "postgres".to_string(), 236 | }; 237 | assert_eq!(deserialized, expected_config); 238 | 239 | let _ = expected_config 240 | .make_authenticator() 241 | .expect("to be constructed correctly"); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /diesel/src/schema.rs: -------------------------------------------------------------------------------- 1 | //! Schema of the `users` table that will be used with Rowdy 2 | //! If you have more sophisticated needs, you are able to add more columns to the basic 3 | //! columns needed for rowdy to work. 4 | //! 5 | //! The schema required for the table as expressed using Diesel's 6 | //! [`table!`](http://docs.diesel.rs/diesel/macro.table.html) macro is 7 | //! 8 | //! ```rust,ignore 9 | //! table! { 10 | //! users (username) { 11 | //! username -> Varchar, 12 | //! hash -> Binary, 13 | //! salt -> Varbinary, 14 | //! } 15 | //! } 16 | //! ``` 17 | //! 18 | //! In standard SQL parlance, this is equivalent to 19 | //! 20 | //! ```sql 21 | //! CREATE TABLE IF NOT EXISTS `users` ( 22 | //! `username` VARCHAR(255) UNIQUE NOT NULL, 23 | //! `hash` BINARY(32) NOT NULL, 24 | //! `salt` VARBINARY(255) NOT NULL, 25 | //! PRIMARY KEY (`username`) 26 | //! ); 27 | //! ``` 28 | 29 | /// Diesel table definition inside a module to allow for some lints 30 | mod table_macro { 31 | #![allow(unused_qualifications, unused_import_braces)] 32 | table! { 33 | /// Table used to hold users and their hashed passwords 34 | /// 35 | /// Hashing is done with the `argon2i` algorithm with a salt. 36 | users (username) { 37 | /// Username for the user. Also the primary key 38 | username -> Varchar, 39 | /// A argon2i hash of the user's password 40 | hash -> Binary, 41 | /// Salt used to generate the password hash 42 | salt -> Varbinary, 43 | } 44 | } 45 | } 46 | // Then we re-export those to public for use. 47 | pub use self::table_macro::*; 48 | 49 | use std::ops::Deref; 50 | 51 | use Connection; 52 | 53 | /// Trait to provide idempotent minimal migration to create the table necessary for `rowdy-diesel` 54 | /// to work. If you have more sophisticated needs, you are able to add more columns to the basic 55 | /// columns needed for rowdy to work. 56 | // TODO: Look into folding this into the base `Authenticator` struct once const generics 57 | // is implemented. See https://github.com/rust-lang/rust/issues/44580 58 | pub trait Migration 59 | where 60 | T: Connection + 'static, 61 | { 62 | /// Connection type for the migration to work with 63 | type Connection: Deref; 64 | 65 | /// Provide a connection for the migration to work with 66 | // TODO: Object safety? 67 | fn connection(&self) -> Result; 68 | 69 | /// Format the migration query based on the connection type 70 | fn migration_query(&self) -> &str; 71 | 72 | /// Provide idempotent minimal migration to create the table necessary for `rowdy-diesel` 73 | /// to work 74 | fn migrate(&self) -> Result<(), ::Error> { 75 | let query = self.migration_query(); 76 | 77 | let connection = self.connection()?; 78 | connection.batch_execute(&query)?; 79 | Ok(()) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /diesel/src/sqlite.rs: -------------------------------------------------------------------------------- 1 | //! SQLite authenticator module 2 | //! 3 | //! Requires `features = ["sqlite"]` in your `Cargo.toml` 4 | use diesel::prelude::*; 5 | use diesel::r2d2::{Builder, ConnectionManager}; 6 | use diesel::sqlite::SqliteConnection; 7 | 8 | use rowdy; 9 | use rowdy::auth::{AuthenticatorConfiguration, Basic}; 10 | 11 | use schema; 12 | use {Error, PooledConnection}; 13 | 14 | /// A rowdy authenticator that uses a SQLite backed database to provide the users 15 | pub type Authenticator = ::Authenticator; 16 | 17 | impl Authenticator { 18 | /// Connect to a SQLite database at a certain path 19 | /// 20 | /// Note: Diesel does not support [URI filenames](https://www.sqlite.org/c3ref/open.html) 21 | /// at this moment. 22 | /// 23 | /// # Warning about in memory databases 24 | /// 25 | /// Rowdy uses a connection pool to SQLite databases. So a distinct 26 | /// [`:memory:` database ](https://www.sqlite.org/inmemorydb.html) is created for ever 27 | /// connection in the pool. Since URI filenames are not supported, 28 | /// `file:memdb1?mode=memory&cache=shared` cannot be used. 29 | pub fn with_path>(path: S) -> Result { 30 | // Attempt a test connection with diesel 31 | let _ = Self::connect(path.as_ref())?; 32 | 33 | let manager = ConnectionManager::new(path.as_ref()); 34 | let pool = Builder::new().build(manager)?; 35 | Ok(Self::new(pool)) 36 | } 37 | 38 | /// Test connection with the database uri 39 | fn connect(path: &str) -> Result { 40 | debug_!("Attempting a connection to SQLite database"); 41 | Ok(SqliteConnection::establish(path)?) 42 | } 43 | } 44 | 45 | impl schema::Migration for Authenticator { 46 | type Connection = PooledConnection>; 47 | 48 | fn connection(&self) -> Result { 49 | self.get_pooled_connection() 50 | } 51 | 52 | fn migration_query(&self) -> &str { 53 | r#"CREATE TABLE IF NOT EXISTS 'users' ( 54 | 'username' VARCHAR(255) UNIQUE NOT NULL, 55 | 'hash' BLOB(32) NOT NULL, 56 | 'salt' BLOB(255) NOT NULL, 57 | PRIMARY KEY ('username') 58 | );"# 59 | } 60 | } 61 | 62 | /// (De)Serializable configuration for SQLite Authenticator. This struct should be included 63 | /// in the base `Configuration`. 64 | /// # Examples 65 | /// ```json 66 | /// { 67 | /// "database": "file:/home/fred/data.db" 68 | /// } 69 | /// ``` 70 | #[derive(Eq, PartialEq, Serialize, Deserialize, Debug)] 71 | pub struct Configuration { 72 | /// Connect to a SQLite database at a certain path 73 | /// 74 | /// Note: Diesel does not support [URI filenames](https://www.sqlite.org/c3ref/open.html) 75 | /// at this moment. 76 | /// 77 | /// # Warning about in memory databases 78 | /// 79 | /// Rowdy uses a connection pool to SQLite databases. So a distinct 80 | /// [`:memory:` database ](https://www.sqlite.org/inmemorydb.html) is created for ever 81 | /// connection in the pool. Since URI filenames are not supported, 82 | /// `file:memdb1?mode=memory&cache=shared` cannot be used. 83 | pub path: String, 84 | } 85 | 86 | impl AuthenticatorConfiguration for Configuration { 87 | type Authenticator = Authenticator; 88 | 89 | fn make_authenticator(&self) -> Result { 90 | Ok(Authenticator::with_path(&self.path)?) 91 | } 92 | } 93 | 94 | #[cfg(test)] 95 | mod tests { 96 | use std::sync::{Once, ONCE_INIT}; 97 | 98 | use diesel::connection::SimpleConnection; 99 | use rowdy::auth::Authenticator; 100 | 101 | use super::*; 102 | use schema::Migration; 103 | 104 | static SEED: Once = ONCE_INIT; 105 | 106 | /// Reset and seed the databse. This should only be run once. 107 | fn migrate_and_seed(authenticator: &super::Authenticator) { 108 | SEED.call_once(|| { 109 | let query = format!( 110 | include_str!("../test/fixtures/sqlite.sql"), 111 | migration = authenticator.migration_query() 112 | ); 113 | 114 | let connection = authenticator.get_pooled_connection().expect("to succeed"); 115 | connection.batch_execute(&query).expect("to work"); 116 | }); 117 | } 118 | 119 | fn make_authenticator() -> super::Authenticator { 120 | let authenticator = super::Authenticator::with_path("../target/sqlite.db") 121 | .expect("To be constructed successfully"); 122 | migrate_and_seed(&authenticator); 123 | authenticator 124 | } 125 | 126 | #[test] 127 | fn hashing_is_done_correctly() { 128 | let hashed_password = super::Authenticator::hash_password("password", &[0; 32]) 129 | .expect("to hash successfully"); 130 | assert_eq!( 131 | "e6e1111452a5574d8d64f6f4ba6fabc86af5c45c341df1eb23026373c41d24b8", 132 | hashed_password 133 | ); 134 | } 135 | 136 | #[test] 137 | fn hashing_is_done_correctly_for_unicode() { 138 | let hashed_password = super::Authenticator::hash_password("冻住,不许走!", &[0; 32]) 139 | .expect("to hash successfully"); 140 | assert_eq!( 141 | "b400a5eea452afcc67a81602f28012e5634404ddf1e043d6ff1df67022c88cd2", 142 | hashed_password 143 | ); 144 | } 145 | 146 | /// Migration should be idempotent 147 | #[test] 148 | fn migration_is_idempotent() { 149 | let authenticator = make_authenticator(); 150 | authenticator 151 | .migrate() 152 | .expect("To succeed and be idempotent") 153 | } 154 | 155 | #[test] 156 | fn authentication_with_username_and_password() { 157 | let authenticator = make_authenticator(); 158 | 159 | let _ = authenticator 160 | .verify("foobar", "password", false) 161 | .expect("To verify correctly"); 162 | 163 | let result = authenticator 164 | .verify("mei", "冻住,不许走!", false) 165 | .expect("to be verified"); 166 | 167 | // refresh refresh_payload is not provided when not requested 168 | assert!(result.refresh_payload.is_none()); 169 | } 170 | 171 | #[test] 172 | fn authentication_with_refresh_payload() { 173 | let authenticator = make_authenticator(); 174 | 175 | let result = authenticator 176 | .verify("foobar", "password", true) 177 | .expect("To verify correctly"); 178 | // refresh refresh_payload is provided when requested 179 | assert!(result.refresh_payload.is_some()); 180 | 181 | let result = authenticator 182 | .authenticate_refresh_token(result.refresh_payload.as_ref().unwrap()) 183 | .expect("to be successful"); 184 | assert!(result.refresh_payload.is_none()); 185 | } 186 | 187 | #[test] 188 | fn sqlite_authenticator_configuration_deserialization() { 189 | use rowdy::auth::AuthenticatorConfiguration; 190 | use serde_json; 191 | 192 | let json = r#"{ 193 | "path": "../target/test.db" 194 | }"#; 195 | 196 | let deserialized: Configuration = 197 | serde_json::from_str(json).expect("to deserialize successfully"); 198 | let expected_config = Configuration { 199 | path: From::from("../target/test.db"), 200 | }; 201 | assert_eq!(deserialized, expected_config); 202 | 203 | let _ = expected_config 204 | .make_authenticator() 205 | .expect("to be constructed correctly"); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /diesel/test/fixtures/mysql.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `users`; 2 | 3 | # Create users table 4 | {migration} 5 | 6 | # Populate with test data 7 | INSERT INTO `users` (username, hash, salt) VALUES 8 | ("mei", X'aac846b3ef07dc88f417cc73775e32724580c17b2068c11b722e9dc6a220c0e8', X'37a82d20d2f53963b1ac7934e9fc9b80c5778bc51bd57ccb33543d2da0d25069'), 9 | ("foobar", X'615585bfbdd7c762174fff0b026881900c29828f504df7f87b213872b057b8dc', X'25c9fee3f2cf30e278aaf8b2b42f18a73dd39b77cfd08bedbe93d9ba3c90befa'); 10 | -------------------------------------------------------------------------------- /diesel/test/fixtures/postgres.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS users; 2 | 3 | -- Create users table 4 | {migration} 5 | 6 | -- Populate with test data 7 | INSERT INTO users (username, hash, salt) VALUES 8 | ('mei', '\xaac846b3ef07dc88f417cc73775e32724580c17b2068c11b722e9dc6a220c0e8', '\x37a82d20d2f53963b1ac7934e9fc9b80c5778bc51bd57ccb33543d2da0d25069'), 9 | ('foobar', '\x615585bfbdd7c762174fff0b026881900c29828f504df7f87b213872b057b8dc', '\x25c9fee3f2cf30e278aaf8b2b42f18a73dd39b77cfd08bedbe93d9ba3c90befa'); 10 | -------------------------------------------------------------------------------- /diesel/test/fixtures/sqlite.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS `users`; 2 | 3 | -- Create users table 4 | {migration} 5 | 6 | -- Populate with test data 7 | INSERT INTO `users` (username, hash, salt) VALUES 8 | ("mei", X'aac846b3ef07dc88f417cc73775e32724580c17b2068c11b722e9dc6a220c0e8', X'37a82d20d2f53963b1ac7934e9fc9b80c5778bc51bd57ccb33543d2da0d25069'), 9 | ("foobar", X'615585bfbdd7c762174fff0b026881900c29828f504df7f87b213872b057b8dc', X'25c9fee3f2cf30e278aaf8b2b42f18a73dd39b77cfd08bedbe93d9ba3c90befa'); 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2.1" 2 | services: 3 | rowdy: 4 | build: . 5 | expose: 6 | - 8000 7 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | nightly 2 | -------------------------------------------------------------------------------- /src/auth/ldap.rs: -------------------------------------------------------------------------------- 1 | //! LDAP Authentication module 2 | use std::collections::HashMap; 3 | 4 | use ldap3::ldap_escape; 5 | use ldap3::{LdapConn, Scope, SearchEntry}; 6 | use serde_json::value; 7 | use strfmt::{strfmt, FmtError}; 8 | 9 | use super::{AuthenticationResult, Basic}; 10 | use crate::{Error, JsonMap, JsonValue}; 11 | 12 | /// Error mapping for `FmtError` 13 | impl From for Error { 14 | fn from(e: FmtError) -> Error { 15 | Error::GenericError(e.to_string()) 16 | } 17 | } 18 | 19 | /// A "User" returned from LDAP. This is the same as `ldap3::SearchEntry`, 20 | /// but with additional traits implemented 21 | #[derive(Clone, Eq, PartialEq, Serialize, Deserialize, Debug)] 22 | pub struct User { 23 | dn: String, 24 | attributes: HashMap>, 25 | } 26 | 27 | impl From for User { 28 | fn from(entry: SearchEntry) -> Self { 29 | Self { 30 | dn: entry.dn, 31 | attributes: entry.attrs, 32 | } 33 | } 34 | } 35 | 36 | /// LDAP based authenticator 37 | /// 38 | /// Use LDAP server as the identity provider. 39 | /// 40 | /// # Example 41 | /// ``` 42 | /// use rowdy::auth::LdapAuthenticator; 43 | /// let authenticator = LdapAuthenticator { 44 | /// address: "ldap://ldap.forumsys.com".to_string(), 45 | /// bind_dn: "cn=read-only-admin,dc=example,dc=com".to_string(), 46 | /// bind_password: "password".to_string(), 47 | /// search_base: "dc=example,dc=com".to_string(), 48 | /// search_filter: Some("(uid={account})".to_string()), 49 | /// include_attributes: vec!["cn".to_string()], 50 | /// attributes_namespace: Some("user".to_string()), 51 | /// subject_attribute: Some("uid".to_string()), 52 | /// }; 53 | /// ``` 54 | #[derive(Clone, Eq, PartialEq, Serialize, Deserialize, Debug)] 55 | pub struct LdapAuthenticator { 56 | /// Location of the LDAP server 57 | pub address: String, 58 | /// The user that we will bind to LDAP to search for users 59 | pub bind_dn: String, 60 | /// The password that we will use to bind to LDAP to search for users 61 | pub bind_password: String, 62 | /// Base to use when searching for user. `{account}` is expanded to the user's account. 63 | /// Search filters _MUST_ be escaped according to RFC 4515. 64 | pub search_base: String, 65 | /// Filter to use when searching for user. `{account}` is expanded to the user's account. 66 | /// Search filters _MUST_ be escaped according to RFC 4515. 67 | #[serde(default, skip_serializing_if = "Option::is_none")] 68 | pub search_filter: Option, 69 | /// List of attributes from the LDAP Search Result Entry to be included in the JWT. The values 70 | /// will be placed under the `attributes_namespace` key in the JWT. 71 | /// Missing keys are silently ignored. 72 | /// 73 | /// When a search with LDAP is performed, a 74 | /// [`SearchEntry`](https://docs.rs/ldap3/0.4.4/ldap3/struct.SearchEntry.html) is returned. 75 | /// You can use this to configure the list of attributes that should be included in the 76 | /// JSON Web Token that will be provided to your applications. 77 | #[serde(default)] 78 | pub include_attributes: Vec, 79 | /// Namespace or Key in the returned JSON Web Token to return `include_attributes` attributes 80 | /// in. See the documentation for the `include_attributes` for more information. 81 | /// 82 | /// If set to `None`, the keys from the LDAP attributes will be merged with that of the JWT, 83 | /// and any duplicate keys will result in errors as described 84 | /// [here](https://github.com/lawliet89/biscuit/issues/78). 85 | #[serde(default)] 86 | pub attributes_namespace: Option, 87 | /// The LDAP attribute to be used as the "subject" of the JWT token issued. By default, the 88 | /// `dn` attribute will be used. Another common attribute would be `cn`. 89 | /// 90 | /// The first value returned by the attribute will be used as the subject. 91 | #[serde(default)] 92 | pub subject_attribute: Option, 93 | } 94 | 95 | impl LdapAuthenticator { 96 | /// Connects to the LDAP server 97 | pub fn connect(&self) -> Result { 98 | debug_!("Connecting to LDAP {}", self.address); 99 | let connection = LdapConn::new(&self.address)?; 100 | Ok(connection) 101 | } 102 | 103 | /// Get the `subject_attribute` setting or return default 104 | fn get_subject_attribute(&self) -> &str { 105 | self.subject_attribute 106 | .as_ref() 107 | .map(String::as_ref) 108 | .unwrap_or("dn") 109 | } 110 | 111 | /// Bind the "searcher" user 112 | fn searcher_bind(&self, connection: &LdapConn) -> Result<(), Error> { 113 | self.bind(connection, &self.bind_dn, &self.bind_password) 114 | } 115 | 116 | /// Bind a connection to some dn 117 | /// 118 | /// # Example 119 | /// ```rust 120 | /// use rowdy::auth::LdapAuthenticator; 121 | /// let authenticator = LdapAuthenticator { 122 | /// address: "ldap://ldap.forumsys.com".to_string(), 123 | /// bind_dn: "cn=read-only-admin,dc=example,dc=com".to_string(), 124 | /// bind_password: "password".to_string(), 125 | /// search_base: "dc=example,dc=com".to_string(), 126 | /// search_filter: Some("(uid={account})".to_string()), 127 | /// include_attributes: vec!["cn".to_string()], 128 | /// attributes_namespace: Some("user".to_string()), 129 | /// subject_attribute: Some("uid".to_string()), 130 | /// }; 131 | /// let connection = authenticator.connect().unwrap(); 132 | /// authenticator.bind(&connection, "cn=read-only-admin,dc=example,dc=com", "password") 133 | /// .expect("To bind successfully"); 134 | /// ``` 135 | pub fn bind(&self, connection: &LdapConn, dn: &str, password: &str) -> Result<(), Error> { 136 | debug_!("Binding to DN {}", dn); 137 | let _s = connection 138 | .simple_bind(dn, password)? 139 | .success() 140 | .map_err(|e| Error::GenericError(format!("Bind failed: {}", e)))?; 141 | Ok(()) 142 | } 143 | 144 | /// Search for the specified account in the directory 145 | fn search(&self, connection: &LdapConn, account: &str) -> Result, Error> { 146 | let account = ldap_escape(account).into(); 147 | let account: HashMap = 148 | [("account".to_string(), account)].iter().cloned().collect(); 149 | let search_base = strfmt(&self.search_base, &account)?; 150 | let search_filter = match self.search_filter { 151 | None => "".to_string(), 152 | Some(ref search_filter) => strfmt(search_filter, &account)?, 153 | }; 154 | 155 | // This specifies what to get back from the LDAP server 156 | let mut search_attrs_vec = vec!["cn", "dn"]; 157 | search_attrs_vec.extend(self.include_attributes.iter().map(String::as_str)); 158 | search_attrs_vec.push(self.get_subject_attribute()); 159 | search_attrs_vec.sort(); 160 | search_attrs_vec.dedup(); 161 | 162 | debug_!( 163 | "Searching base {} with filter {} and attributes {:?}", 164 | search_base, 165 | search_filter, 166 | search_attrs_vec 167 | ); 168 | 169 | let (results, _) = connection 170 | .search( 171 | &search_base, 172 | Scope::Subtree, 173 | &search_filter, 174 | search_attrs_vec, 175 | )? 176 | .success() 177 | .map_err(|e| Error::GenericError(format!("Search failed: {}", e)))?; 178 | 179 | Ok(results.into_iter().map(SearchEntry::construct).collect()) 180 | } 181 | 182 | /// Serialize a user as payload for a refresh token 183 | fn serialize_refresh_token_payload(user: &User) -> Result { 184 | let user = value::to_value(user).map_err(|_| super::Error::AuthenticationFailure)?; 185 | let mut map = JsonMap::with_capacity(1); 186 | let _ = map.insert("user".to_string(), user); 187 | Ok(JsonValue::Object(map)) 188 | } 189 | 190 | /// Deserialize a user from a refresh token payload 191 | fn deserialize_refresh_token_payload(refresh_payload: JsonValue) -> Result { 192 | match refresh_payload { 193 | JsonValue::Object(ref map) => { 194 | let user = map 195 | .get("user") 196 | .ok_or_else(|| Error::Auth(super::Error::AuthenticationFailure))?; 197 | Ok(value::from_value(user.clone()) 198 | .map_err(|_| super::Error::AuthenticationFailure)?) 199 | } 200 | _ => Err(Error::Auth(super::Error::AuthenticationFailure)), 201 | } 202 | } 203 | 204 | /// Build an `AuthenticationResult` for a `User` 205 | fn build_authentication_result>( 206 | user: &User, 207 | subject: &str, 208 | include_attributes: &[T], 209 | attributes_namespace: Option<&str>, 210 | include_refresh_payload: bool, 211 | ) -> Result { 212 | // Include LDAP attributes 213 | let (map, errors): (Vec<_>, Vec<_>) = include_attributes 214 | .iter() 215 | .filter(|key| user.attributes.contains_key(key.as_ref())) 216 | .map(|key| { 217 | // Safe to unwrap 218 | let attribute = &user.attributes[key.as_ref()]; 219 | Ok(( 220 | key.as_ref().to_string(), 221 | value::to_value(attribute).map_err(|e| e.to_string())?, 222 | )) 223 | }) 224 | .partition(Result::is_ok); 225 | 226 | if !errors.is_empty() { 227 | let errors: Vec = errors.into_iter().map(|r| r.unwrap_err()).collect(); 228 | Err(errors.join("; "))?; 229 | } 230 | 231 | let map: JsonMap<_, _> = map 232 | .into_iter() 233 | .map(|tuple| { 234 | // Safe to unwrap 235 | tuple.unwrap() 236 | }) 237 | .collect(); 238 | 239 | let private_claims = match attributes_namespace { 240 | None => JsonValue::Object(map), 241 | Some(namespace) => { 242 | let outer_map: JsonMap<_, _> = 243 | vec![(namespace.to_string(), JsonValue::Object(map))] 244 | .into_iter() 245 | .collect(); 246 | JsonValue::Object(outer_map) 247 | } 248 | }; 249 | 250 | let refresh_payload = if include_refresh_payload { 251 | Some(Self::serialize_refresh_token_payload(user)?) 252 | } else { 253 | None 254 | }; 255 | 256 | Ok(AuthenticationResult { 257 | subject: subject.to_string(), 258 | private_claims, 259 | refresh_payload, 260 | }) 261 | } 262 | 263 | /// Based on the current settings, retrieve the subject for the `User` struct 264 | fn get_user_subject<'a>(&self, user: &'a User) -> Result<&'a str, Error> { 265 | match self.get_subject_attribute() { 266 | "dn" => Ok(&user.dn), 267 | attribute => { 268 | let values = user.attributes.get(attribute).ok_or_else(|| { 269 | format!( 270 | "{} attribute was not returned and cannot be used as the subject", 271 | attribute 272 | ) 273 | })?; 274 | let first_value = values.first().ok_or_else(|| { 275 | format!( 276 | "{} attribute does not have any value and cannot be used as the subject", 277 | attribute 278 | ) 279 | })?; 280 | Ok(first_value) 281 | } 282 | } 283 | } 284 | 285 | /// Authenticate the user with the username/password 286 | pub fn verify( 287 | &self, 288 | username: &str, 289 | password: &str, 290 | include_refresh_payload: bool, 291 | ) -> Result { 292 | let user = { 293 | // First, we search for the user 294 | let connection = self.connect()?; 295 | self.searcher_bind(&connection)?; 296 | let mut user = self.search(&connection, username).map_err(|e| { 297 | error_!("Error searching for username `{}`: {}", username, e); 298 | super::Error::AuthenticationFailure 299 | })?; 300 | if user.len() != 1 { 301 | error_!( 302 | "{} users were returned for the username {}", 303 | user.len(), 304 | username 305 | ); 306 | Err(super::Error::AuthenticationFailure)?; 307 | } 308 | 309 | user.pop().unwrap() // safe to unwrap 310 | }; 311 | 312 | let user_dn = user.dn.clone(); 313 | 314 | { 315 | // Attempt a bind with the user's DN and password 316 | let connection = self.connect()?; 317 | self.bind(&connection, &user_dn, password).map_err(|e| { 318 | error_!( 319 | "Error binding DN {} with user supplied password: {}", 320 | user_dn, 321 | e 322 | ); 323 | super::Error::AuthenticationFailure 324 | })?; 325 | } 326 | 327 | let user = From::from(user); 328 | Self::build_authentication_result( 329 | &user, 330 | self.get_user_subject(&user)?, 331 | self.include_attributes.as_slice(), 332 | self.attributes_namespace.as_ref().map(String::as_ref), 333 | include_refresh_payload, 334 | ) 335 | } 336 | } 337 | 338 | impl super::Authenticator for LdapAuthenticator { 339 | fn authenticate( 340 | &self, 341 | authorization: &super::Authorization, 342 | include_refresh_payload: bool, 343 | ) -> Result { 344 | let username = authorization.username(); 345 | let password = authorization.password().unwrap_or_else(|| "".to_string()); 346 | self.verify(&username, &password, include_refresh_payload) 347 | } 348 | 349 | // TODO: Implement retrieving updated information from LDAP server 350 | fn authenticate_refresh_token( 351 | &self, 352 | refresh_payload: &JsonValue, 353 | ) -> Result { 354 | let user = Self::deserialize_refresh_token_payload(refresh_payload.clone())?; 355 | Self::build_authentication_result( 356 | &user, 357 | self.get_user_subject(&user)?, 358 | self.include_attributes.as_slice(), 359 | self.attributes_namespace.as_ref().map(String::as_ref), 360 | false, 361 | ) 362 | } 363 | } 364 | 365 | impl super::AuthenticatorConfiguration for LdapAuthenticator { 366 | type Authenticator = LdapAuthenticator; 367 | 368 | fn make_authenticator(&self) -> Result { 369 | { 370 | // Test connection to LDAP server 371 | let connection = self.connect()?; 372 | // Test binding for user searcher 373 | self.searcher_bind(&connection)?; 374 | } 375 | 376 | Ok(self.clone()) 377 | } 378 | } 379 | 380 | #[cfg(test)] 381 | mod tests { 382 | //! These tests might intermittently fail due to Test server being inaccessible 383 | use super::*; 384 | use crate::auth::Authenticator; 385 | 386 | /// Test LDAP server: 387 | /// http://www.forumsys.com/tutorials/integration-how-to/ldap/online-ldap-test-server/ 388 | fn make_authenticator() -> LdapAuthenticator { 389 | LdapAuthenticator { 390 | address: "ldap://ldap.forumsys.com".to_string(), 391 | bind_dn: "cn=read-only-admin,dc=example,dc=com".to_string(), 392 | bind_password: "password".to_string(), 393 | search_base: "dc=example,dc=com".to_string(), 394 | search_filter: Some("(uid={account})".to_string()), 395 | include_attributes: vec!["cn".to_string()], 396 | attributes_namespace: None, 397 | subject_attribute: Some("uid".to_string()), 398 | } 399 | } 400 | 401 | fn make_user() -> User { 402 | User { 403 | dn: "CN=John Doe,CN=Users,DC=acme,DC=example,DC=com".to_string(), 404 | attributes: vec![ 405 | ("cn".to_string(), vec!["John Doe".to_string()]), 406 | ("uid".to_string(), vec!["john.doe".to_string()]), 407 | ( 408 | "memberOf".to_string(), 409 | vec!["admins".to_string(), "user".to_string()], 410 | ), 411 | ] 412 | .into_iter() 413 | .collect(), 414 | } 415 | } 416 | 417 | #[test] 418 | fn get_subject_attribute_returns_correctly() { 419 | let mut authenticator = make_authenticator(); 420 | assert_eq!("uid", authenticator.get_subject_attribute()); 421 | 422 | authenticator.subject_attribute = None; 423 | assert_eq!("dn", authenticator.get_subject_attribute()); 424 | } 425 | 426 | #[test] 427 | fn get_user_subject_returns_correctly() { 428 | let mut authenticator = make_authenticator(); 429 | let user = make_user(); 430 | 431 | let subject = not_err!(authenticator.get_user_subject(&user)); 432 | assert_eq!("john.doe", subject); 433 | 434 | authenticator.subject_attribute = None; 435 | let subject = not_err!(authenticator.get_user_subject(&user)); 436 | assert_eq!("CN=John Doe,CN=Users,DC=acme,DC=example,DC=com", subject); 437 | } 438 | 439 | #[test] 440 | #[should_panic(expected = "attribute was not returned")] 441 | fn get_user_subject_errors_on_missing_attribute() { 442 | let mut authenticator = make_authenticator(); 443 | let user = make_user(); 444 | 445 | authenticator.subject_attribute = Some("does not exist".to_string()); 446 | let _ = authenticator.get_user_subject(&user).unwrap(); 447 | } 448 | 449 | #[test] 450 | #[should_panic(expected = "attribute does not have any value")] 451 | fn get_user_subject_errors_on_empty_attribute() { 452 | let mut authenticator = make_authenticator(); 453 | let mut user = make_user(); 454 | 455 | authenticator.subject_attribute = Some("empty".to_string()); 456 | let _ = user.attributes.insert("empty".to_string(), vec![]); 457 | let _ = authenticator.get_user_subject(&user).unwrap(); 458 | } 459 | 460 | #[test] 461 | fn authentication() { 462 | let mut expected_map = JsonMap::new(); 463 | let _ = expected_map.insert("cn".to_string(), From::from(vec!["Leonhard Euler"])); 464 | let expected_private_claim = JsonValue::Object(expected_map); 465 | 466 | let authenticator = make_authenticator(); 467 | 468 | let result = not_err!(authenticator.verify("euler", "password", false)); 469 | assert_eq!(result.subject, "euler"); 470 | assert!(result.refresh_payload.is_none()); 471 | assert_eq!(result.private_claims, expected_private_claim); 472 | 473 | let result = not_err!(authenticator.verify("euler", "password", true)); 474 | assert_eq!(result.subject, "euler"); 475 | assert!(result.refresh_payload.is_some()); 476 | assert_eq!(result.private_claims, expected_private_claim); 477 | 478 | let refresh_result = not_err!( 479 | authenticator.authenticate_refresh_token(result.refresh_payload.as_ref().unwrap(),) 480 | ); 481 | assert!(refresh_result.refresh_payload.is_none()); 482 | 483 | assert_eq!(result.subject, refresh_result.subject); 484 | } 485 | 486 | #[test] 487 | fn attributes_are_included_correctly() { 488 | let result = not_err!(LdapAuthenticator::build_authentication_result( 489 | &make_user(), 490 | "john.doe", 491 | vec!["cn", "memberOf"].as_slice(), 492 | None, 493 | false, 494 | )); 495 | let expected_attributes: JsonMap<_, _> = vec![ 496 | ("cn".to_string(), vec!["John Doe".to_string()]), 497 | ( 498 | "memberOf".to_string(), 499 | vec!["admins".to_string(), "user".to_string()], 500 | ), 501 | ] 502 | .into_iter() 503 | .map(|(key, value)| (key, value::to_value(value).unwrap())) 504 | .collect(); 505 | 506 | assert_eq!( 507 | JsonValue::Object(expected_attributes), 508 | result.private_claims 509 | ); 510 | } 511 | 512 | #[test] 513 | fn attributes_are_namespaced_correctly() { 514 | let result = not_err!(LdapAuthenticator::build_authentication_result( 515 | &make_user(), 516 | "john.doe", 517 | vec!["cn", "memberOf"].as_slice(), 518 | Some("namespace"), 519 | false, 520 | )); 521 | let expected_attributes: JsonMap<_, _> = vec![ 522 | ("cn".to_string(), vec!["John Doe".to_string()]), 523 | ( 524 | "memberOf".to_string(), 525 | vec!["admins".to_string(), "user".to_string()], 526 | ), 527 | ] 528 | .into_iter() 529 | .map(|(key, value)| (key, value::to_value(value).unwrap())) 530 | .collect(); 531 | 532 | let namespaced_attributes: JsonMap<_, _> = vec![( 533 | "namespace".to_string(), 534 | JsonValue::Object(expected_attributes), 535 | )] 536 | .into_iter() 537 | .collect(); 538 | 539 | assert_eq!( 540 | JsonValue::Object(namespaced_attributes), 541 | result.private_claims 542 | ); 543 | } 544 | 545 | #[test] 546 | fn missing_attributes_are_ignored() { 547 | let result = not_err!(LdapAuthenticator::build_authentication_result( 548 | &make_user(), 549 | "john.doe", 550 | vec!["cn", "not_exist"].as_slice(), 551 | None, 552 | false, 553 | )); 554 | let expected_attributes: JsonMap<_, _> = 555 | vec![("cn".to_string(), vec!["John Doe".to_string()])] 556 | .into_iter() 557 | .map(|(key, value)| (key, value::to_value(value).unwrap())) 558 | .collect(); 559 | 560 | assert_eq!( 561 | JsonValue::Object(expected_attributes), 562 | result.private_claims 563 | ); 564 | } 565 | 566 | #[test] 567 | #[should_panic(expected = "AuthenticationFailure")] 568 | fn authentication_invalid_user() { 569 | let authenticator = make_authenticator(); 570 | let _ = authenticator 571 | .verify("donald_trump", "password", false) 572 | .unwrap(); 573 | } 574 | 575 | #[test] 576 | #[should_panic(expected = "AuthenticationFailure")] 577 | fn authentication_invalid_password() { 578 | let authenticator = make_authenticator(); 579 | let _ = authenticator.verify("einstein", "FTL", false).unwrap(); 580 | } 581 | } 582 | -------------------------------------------------------------------------------- /src/auth/mod.rs: -------------------------------------------------------------------------------- 1 | //! Authentication module, including traits for identity provider and `Responder`s for 2 | //! authentication. 3 | use std::error; 4 | use std::fmt; 5 | use std::ops::Deref; 6 | 7 | use hyper; 8 | use hyper::header; 9 | use rocket; 10 | use rocket::http::Status; 11 | use rocket::request::{self, FromRequest, Request}; 12 | use rocket::response; 13 | use rocket::Outcome; 14 | use serde::de::DeserializeOwned; 15 | use serde::Serialize; 16 | 17 | pub mod util; 18 | 19 | mod noop; 20 | pub use self::noop::NoOp; 21 | pub use self::noop::NoOpConfiguration; 22 | 23 | #[cfg(feature = "simple_authenticator")] 24 | pub mod simple; 25 | #[cfg(feature = "simple_authenticator")] 26 | pub use self::simple::SimpleAuthenticator; 27 | #[cfg(feature = "simple_authenticator")] 28 | pub use self::simple::SimpleAuthenticatorConfiguration; 29 | 30 | #[cfg(feature = "ldap_authenticator")] 31 | mod ldap; 32 | #[cfg(feature = "ldap_authenticator")] 33 | pub use self::ldap::LdapAuthenticator; 34 | 35 | use crate::JsonValue; 36 | 37 | /// Re-exported [`hyper::header::Scheme`] 38 | pub type Scheme = dyn hyper::header::Scheme; 39 | /// Re-exported [`hyper::header::Basic`]. 40 | pub type Basic = hyper::header::Basic; 41 | /// Re-exported [`hyper::header::Bearer`]. 42 | pub type Bearer = hyper::header::Bearer; 43 | 44 | /// A typedef for an `Authenticator` trait object that requires HTTP Basic authentication 45 | pub type BasicAuthenticator = dyn Authenticator; 46 | /// A typedef for an `Authenticator` trait object that requires Bearer authentication. 47 | pub type BearerAuthenticator = dyn Authenticator; 48 | /// A typedef for an `Authenticator` trait object that uses an arbitrary string 49 | pub type StringAuthenticator = dyn Authenticator; 50 | 51 | /// Authentication errors 52 | #[derive(Debug)] 53 | pub enum Error { 54 | /// Authentication was attempted successfully, but failed because of bad user credentials, 55 | /// or other reasons. 56 | AuthenticationFailure, 57 | /// A generic error 58 | GenericError(String), 59 | /// An error due to `hyper`, such as header parsing failure 60 | HyperError(hyper::error::Error), 61 | /// The `Authorization` HTTP request header was required but was missing. This variant will 62 | /// `respond` with the 63 | /// appropriate `WWW-Authenticate` header. 64 | MissingAuthorization { 65 | /// The HTTP basic authentication realm 66 | realm: String, 67 | }, 68 | } 69 | 70 | impl_from_error!(String, Error::GenericError); 71 | impl_from_error!(hyper::error::Error, Error::HyperError); 72 | 73 | impl error::Error for Error { 74 | fn description(&self) -> &str { 75 | match *self { 76 | Error::AuthenticationFailure => "Authentication has failed", 77 | Error::MissingAuthorization { .. } => { 78 | "The request header `Authorization` is required but is missing" 79 | } 80 | Error::GenericError(ref e) => &**e, 81 | Error::HyperError(ref e) => e.description(), 82 | } 83 | } 84 | 85 | fn cause(&self) -> Option<&dyn error::Error> { 86 | match *self { 87 | Error::HyperError(ref e) => Some(e), 88 | _ => Some(self), 89 | } 90 | } 91 | } 92 | 93 | impl fmt::Display for Error { 94 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 95 | match *self { 96 | Error::HyperError(ref e) => fmt::Display::fmt(e, f), 97 | _ => write!(f, "{}", error::Error::description(self)), 98 | } 99 | } 100 | } 101 | 102 | impl<'r> response::Responder<'r> for Error { 103 | fn respond_to(self, _: &Request<'_>) -> Result, Status> { 104 | error_!("Authentication Error: {:?}", self); 105 | match self { 106 | Error::MissingAuthorization { ref realm } => { 107 | // TODO: Support other schemes! 108 | let www_header = 109 | rocket::http::Header::new("WWW-Authenticate", format!("Basic realm={}", realm)); 110 | 111 | Ok(response::Response::build() 112 | .status(Status::Unauthorized) 113 | .header(www_header) 114 | .finalize()) 115 | } 116 | Error::AuthenticationFailure => Err(Status::Unauthorized), 117 | Error::HyperError(_) => Err(Status::BadRequest), 118 | _ => Err(Status::InternalServerError), 119 | } 120 | } 121 | } 122 | 123 | /// `Authorization` HTTP Request Header 124 | #[derive(Debug)] 125 | pub struct Authorization(pub header::Authorization); 126 | 127 | impl<'a, 'r, S: header::Scheme + 'static> FromRequest<'a, 'r> for Authorization { 128 | type Error = Error; 129 | 130 | fn from_request(request: &'a Request<'r>) -> request::Outcome { 131 | match request.headers().get_one("Authorization") { 132 | Some(authorization) => match Self::new(authorization) { 133 | Err(_) => Outcome::Forward(()), 134 | Ok(parsed) => Outcome::Success(parsed), 135 | }, 136 | None => Outcome::Forward(()), 137 | } 138 | } 139 | } 140 | 141 | impl Authorization { 142 | /// Create a new Authorization header 143 | pub fn new<'a>(header: &'a str) -> Result { 144 | use hyper::header::Header; 145 | 146 | let bytes: Vec = header.as_bytes().to_vec(); 147 | let parsed = header::Authorization::parse_header(&[bytes])?; 148 | Ok(Authorization(parsed)) 149 | } 150 | 151 | /// Convenience function to check if the Authorization is `Basic` 152 | pub fn is_basic(&self) -> bool { 153 | if let Some("Basic") = S::scheme() { 154 | true 155 | } else { 156 | false 157 | } 158 | } 159 | 160 | /// Convenience function to check if the Authorization is `Bearer` 161 | pub fn is_bearer(&self) -> bool { 162 | if let Some("Bearer") = S::scheme() { 163 | true 164 | } else { 165 | false 166 | } 167 | } 168 | 169 | /// Convenience function to check if the Authorization is `None` 170 | pub fn is_string(&self) -> bool { 171 | S::scheme().is_none() 172 | } 173 | } 174 | 175 | impl Authorization { 176 | /// Convenience method to retrieve the username from a HTTP Basic Authorization request header 177 | pub fn username(&self) -> String { 178 | let Authorization(header::Authorization(Basic { ref username, .. })) = *self; 179 | username.to_string() 180 | } 181 | 182 | /// Convenience method to retrieve the password from a HTTP Basic Authorization request header 183 | pub fn password(&self) -> Option { 184 | let Authorization(header::Authorization(Basic { ref password, .. })) = *self; 185 | password.clone() 186 | } 187 | } 188 | 189 | impl Authorization { 190 | /// Convenience method to retrieve the token from a bearer Authorization request header. 191 | pub fn token(&self) -> String { 192 | let Authorization(header::Authorization(Bearer { ref token })) = *self; 193 | token.to_string() 194 | } 195 | } 196 | 197 | impl Authorization { 198 | /// Convenience method to retrieve the token from an arbitrary Authorization request header. 199 | pub fn string(&self) -> String { 200 | let Authorization(header::Authorization(ref s)) = *self; 201 | s.to_string() 202 | } 203 | } 204 | 205 | impl Deref for Authorization { 206 | type Target = header::Authorization; 207 | 208 | fn deref(&self) -> &Self::Target { 209 | &self.0 210 | } 211 | } 212 | 213 | /// Authenticator trait to be implemented by identity provider (idp) adapters to 214 | /// provide authentication. 215 | /// Each idp may support all the 216 | /// [schemes](https://hyper.rs/hyper/v0.10.5/hyper/header/trait.Scheme.html) 217 | /// supported, or just one. 218 | /// 219 | /// Usually, you will want to include an `Authenticator` trait object as part of Rocket's 220 | /// [managed state](https://rocket.rs/guide/state/). Before you can do that, however, 221 | /// you will need to `Box` it up. 222 | /// See example below. 223 | /// 224 | /// # Examples 225 | /// 226 | /// # No-op authenticator 227 | /// You can refer to the [source code](../../src/rowdy/auth/noop.rs.html) for the `NoOp` 228 | /// authenticator for a simple implementation. 229 | /// 230 | /// ## Simple Authenticator 231 | /// 232 | /// Refer to the `MockAuthenticator`[../../src/rowdy/auth/mod.rs.html] implemented 233 | /// in the test code for this module. 234 | pub trait Authenticator: Send + Sync { 235 | /// Verify the credentials provided in the headers with the authenticator for 236 | /// the initial issuing of Access Tokens. 237 | /// 238 | /// If the Authenticator supports re-issuing of access tokens subsequently using refresh tokens, 239 | /// and it is requested for, the function should return a `JsonValue` 240 | /// containing the payload to include with the refresh token. 241 | /// 242 | /// Users should not user`authenticate` directly and use `prepare_authentication_response` 243 | /// instead. 244 | fn authenticate( 245 | &self, 246 | authorization: &Authorization, 247 | include_refresh_payload: bool, 248 | ) -> Result; 249 | 250 | /// Verify the credentials provided with the refresh token payload, if supported by the 251 | /// authenticator. 252 | /// 253 | /// A default implementation that returns an `Err(::Error::UnsupportedOperation)` is provided. 254 | /// 255 | /// Users should not user `authenticate` directly and use `prepare_refresh_response` instead. 256 | fn authenticate_refresh_token( 257 | &self, 258 | _payload: &JsonValue, 259 | ) -> Result { 260 | Err(crate::Error::UnsupportedOperation) 261 | } 262 | 263 | /// Prepare a response to an authentication request 264 | /// by first verifying credentials. If validation fails, will return an `Err` with the response 265 | /// to be sent. Otherwise, the unwrapped authentication result will be returned in an `Ok`. 266 | /// This function will also check that the authenticator behaves correctly by checking that 267 | /// it does not return a refresh token payload when it is not requested for 268 | fn prepare_authentication_response( 269 | &self, 270 | authorization: &Authorization, 271 | request_refresh_token: bool, 272 | ) -> Result { 273 | let result = self.authenticate(authorization, request_refresh_token)?; 274 | if !request_refresh_token && result.refresh_payload.is_some() { 275 | Err(Error::GenericError( 276 | "Misbehaving authenticator: refresh token payload was \ 277 | returned when it was not requested for" 278 | .to_string(), 279 | ))?; 280 | } 281 | Ok(result) 282 | } 283 | 284 | /// Prepare a response to a refresh request by first verifying the refresh payload. 285 | /// 286 | /// If validation fails, will return an `Err` with the response 287 | /// to be sent. Otherwise, the unwrapped authentication result will be returned in an `Ok`. 288 | /// This function will also check that the authenticator behaves correctly by checking that 289 | /// it does not return a refresh token payload 290 | fn prepare_refresh_response( 291 | &self, 292 | refresh_payload: &JsonValue, 293 | ) -> Result { 294 | let result = self.authenticate_refresh_token(refresh_payload)?; 295 | if result.refresh_payload.is_some() { 296 | Err(Error::GenericError( 297 | "Misbehaving authenticator: refresh token payload was \ 298 | returned for a refresh operation" 299 | .to_string(), 300 | ))?; 301 | } 302 | Ok(result) 303 | } 304 | } 305 | 306 | /// Convenience function to respond with a missing authorization error 307 | pub fn missing_authorization(realm: &str) -> Result { 308 | Err(Error::MissingAuthorization { 309 | realm: realm.to_string(), 310 | })? 311 | } 312 | 313 | /// Configuration for the associated type `Authenticator`. [`crate::Configuration`] expects its 314 | /// `authenticator` field to implement this trait. 315 | /// 316 | /// Before launching, `rowdy` will attempt to make an `Authenticator` based off the 317 | /// configuration by calling the `make_authenticator` method. 318 | pub trait AuthenticatorConfiguration: 319 | Send + Sync + Serialize + DeserializeOwned + 'static 320 | { 321 | /// The `Authenticator` type this configuration is associated with 322 | type Authenticator: Authenticator; 323 | 324 | /// Using the configuration struct, create a new `Authenticator`. 325 | fn make_authenticator(&self) -> Result; 326 | } 327 | 328 | /// Result from a successful authentication operation 329 | #[derive(Clone, PartialEq, Debug)] 330 | pub struct AuthenticationResult { 331 | /// The subject of the authentication 332 | pub subject: String, 333 | /// Additional private claims to be included in the authentication token, if any 334 | pub private_claims: JsonValue, 335 | /// The payload to be included in a Refresh token, if any 336 | pub refresh_payload: Option, 337 | } 338 | 339 | #[cfg(test)] 340 | pub mod tests { 341 | #[allow(deprecated)] 342 | use hyper::header::{self, Header, HeaderFormatter}; 343 | use rocket::http; 344 | use rocket::local::Client; 345 | use rocket::{self, Rocket, State}; 346 | 347 | use super::*; 348 | use crate::{Error, JsonMap}; 349 | 350 | /// Mock authenticator that authenticates only the following: 351 | /// 352 | /// - Basic: user `mei` with password `冻住,不许走!` 353 | /// - Bearer: token `这样可以挡住他们。` 354 | /// - String: 哦,对不起啦。 355 | pub struct MockAuthenticator {} 356 | 357 | /// Payload for the `MockAuthenticator` Refresh Token 358 | #[derive(Serialize, Deserialize, Debug)] 359 | struct RefreshTokenPayload { 360 | header: String, 361 | } 362 | 363 | impl MockAuthenticator { 364 | /// Convert a header to string 365 | #[allow(deprecated)] 366 | fn format(authorization: &header::Authorization) -> String { 367 | HeaderFormatter(authorization).to_string() 368 | } 369 | 370 | /// Generate a refresh token payload from the header 371 | fn serialize_refresh_token_payload( 372 | authorization: &header::Authorization, 373 | ) -> JsonValue { 374 | let string = From::from(Self::format(authorization)); 375 | let mut map = JsonMap::with_capacity(1); 376 | let _ = map.insert("header".to_string(), string); 377 | JsonValue::Object(map) 378 | } 379 | 380 | /// From a refresh token payload, get the header back 381 | /// 382 | /// # Panics 383 | /// Panics if the refresh token payload is not in the right shape, 384 | /// or if the content is invalid 385 | fn deserialize_refresh_token_payload( 386 | refresh_payload: &JsonValue, 387 | ) -> header::Authorization { 388 | match *refresh_payload { 389 | JsonValue::Object(ref map) => { 390 | // will panic if the shape is incorrect 391 | let header = map["header"].as_str().unwrap(); 392 | let header = header.as_bytes().to_vec(); 393 | header::Authorization::parse_header(&[header]).unwrap() 394 | } 395 | _ => panic!("Refresh token payload was not a map"), 396 | } 397 | } 398 | } 399 | 400 | impl Authenticator for MockAuthenticator { 401 | fn authenticate( 402 | &self, 403 | authorization: &Authorization, 404 | include_refresh_payload: bool, 405 | ) -> Result { 406 | let username = authorization.username(); 407 | let password = authorization.password().unwrap_or_else(|| "".to_string()); 408 | 409 | if username == "mei" && password == "冻住,不许走!" { 410 | let refresh_payload = if include_refresh_payload { 411 | Some(Self::serialize_refresh_token_payload(authorization)) 412 | } else { 413 | None 414 | }; 415 | Ok(AuthenticationResult { 416 | subject: username, 417 | private_claims: JsonValue::Object(JsonMap::new()), 418 | refresh_payload, 419 | }) 420 | } else { 421 | Err(super::Error::AuthenticationFailure)? 422 | } 423 | } 424 | 425 | fn authenticate_refresh_token( 426 | &self, 427 | refresh_payload: &JsonValue, 428 | ) -> Result { 429 | let header: header::Authorization = 430 | Self::deserialize_refresh_token_payload(refresh_payload); 431 | self.authenticate(&Authorization(header), false) 432 | } 433 | } 434 | 435 | impl Authenticator for MockAuthenticator { 436 | fn authenticate( 437 | &self, 438 | authorization: &Authorization, 439 | include_refresh_payload: bool, 440 | ) -> Result { 441 | let token = authorization.token(); 442 | 443 | if token == "这样可以挡住他们。" { 444 | let refresh_payload = if include_refresh_payload { 445 | Some(Self::serialize_refresh_token_payload(authorization)) 446 | } else { 447 | None 448 | }; 449 | Ok(AuthenticationResult { 450 | subject: "这样可以挡住他们。".to_string(), 451 | private_claims: JsonValue::Object(JsonMap::new()), 452 | refresh_payload, 453 | }) 454 | } else { 455 | Err(super::Error::AuthenticationFailure)? 456 | } 457 | } 458 | 459 | fn authenticate_refresh_token( 460 | &self, 461 | refresh_payload: &JsonValue, 462 | ) -> Result { 463 | let header: header::Authorization = 464 | Self::deserialize_refresh_token_payload(refresh_payload); 465 | self.authenticate(&Authorization(header), false) 466 | } 467 | } 468 | 469 | impl Authenticator for MockAuthenticator { 470 | fn authenticate( 471 | &self, 472 | authorization: &Authorization, 473 | include_refresh_payload: bool, 474 | ) -> Result { 475 | let string = authorization.string(); 476 | 477 | if string == "哦,对不起啦。" { 478 | let refresh_payload = if include_refresh_payload { 479 | Some(Self::serialize_refresh_token_payload(authorization)) 480 | } else { 481 | None 482 | }; 483 | Ok(AuthenticationResult { 484 | subject: "哦,对不起啦。".to_string(), 485 | private_claims: JsonValue::Object(JsonMap::new()), 486 | refresh_payload, 487 | }) 488 | } else { 489 | Err(super::Error::AuthenticationFailure)? 490 | } 491 | } 492 | 493 | fn authenticate_refresh_token( 494 | &self, 495 | refresh_payload: &JsonValue, 496 | ) -> Result { 497 | let header: header::Authorization = 498 | Self::deserialize_refresh_token_payload(refresh_payload); 499 | self.authenticate(&Authorization(header), false) 500 | } 501 | } 502 | 503 | /// Configuration struct for `MockAuthenticator`. 504 | #[derive(Serialize, Deserialize, Debug)] 505 | pub struct MockAuthenticatorConfiguration {} 506 | 507 | impl AuthenticatorConfiguration for MockAuthenticatorConfiguration 508 | where 509 | S: header::Scheme + 'static, 510 | MockAuthenticator: Authenticator, 511 | { 512 | type Authenticator = MockAuthenticator; 513 | 514 | fn make_authenticator(&self) -> Result { 515 | Ok(Self::Authenticator {}) 516 | } 517 | } 518 | 519 | /// Ignite a Rocket with a Basic authenticator 520 | pub fn ignite_basic(authenticator: Box>) -> Rocket { 521 | // Ignite rocket 522 | rocket::ignite() 523 | .mount("/", routes![auth_basic]) 524 | .manage(authenticator) 525 | } 526 | 527 | #[get("/")] 528 | #[allow(unmounted_route)] 529 | #[allow(needless_pass_by_value)] 530 | fn auth_basic( 531 | authorization: Option>, 532 | authenticator: State<'_, Box>>, 533 | ) -> Result<(), Error> { 534 | let authorization = authorization 535 | .ok_or_else(|| missing_authorization::<()>("https://www.acme.com").unwrap_err())?; 536 | authenticator 537 | .prepare_authentication_response(&authorization, true) 538 | .and_then(|_| Ok(())) 539 | } 540 | 541 | /// Ignite a Rocket with a Bearer authenticator 542 | pub fn ignite_bearer(authenticator: Box>) -> Rocket { 543 | // Ignite rocket 544 | rocket::ignite() 545 | .mount("/", routes![auth_bearer]) 546 | .manage(authenticator) 547 | } 548 | 549 | #[get("/")] 550 | #[allow(unmounted_route)] 551 | #[allow(needless_pass_by_value)] 552 | fn auth_bearer( 553 | authorization: Option>, 554 | authenticator: State<'_, Box>>, 555 | ) -> Result<(), Error> { 556 | let authorization = authorization 557 | .ok_or_else(|| missing_authorization::<()>("https://www.acme.com").unwrap_err())?; 558 | authenticator 559 | .prepare_authentication_response(&authorization, true) 560 | .and_then(|_| Ok(())) 561 | } 562 | 563 | /// Ignite a Rocket with a String authenticator 564 | pub fn ignite_string(authenticator: Box>) -> Rocket { 565 | // Ignite rocket 566 | rocket::ignite() 567 | .mount("/", routes![auth_string]) 568 | .manage(authenticator) 569 | } 570 | 571 | #[get("/")] 572 | #[allow(unmounted_route)] 573 | #[allow(needless_pass_by_value)] 574 | fn auth_string( 575 | authorization: Option>, 576 | authenticator: State<'_, Box>>, 577 | ) -> Result<(), Error> { 578 | let authorization = authorization 579 | .ok_or_else(|| missing_authorization::<()>("https://www.acme.com").unwrap_err())?; 580 | authenticator 581 | .prepare_authentication_response(&authorization, true) 582 | .and_then(|_| Ok(())) 583 | } 584 | 585 | #[test] 586 | #[allow(deprecated)] 587 | fn parses_basic_auth_correctly() { 588 | let auth = header::Authorization(Basic { 589 | username: "Aladdin".to_owned(), 590 | password: Some("open sesame".to_string()), 591 | }); 592 | 593 | let header = HeaderFormatter(&auth).to_string(); 594 | let parsed_header = not_err!(Authorization::new(&header)); 595 | let Authorization(header::Authorization(Basic { username, password })) = parsed_header; 596 | assert_eq!(username, "Aladdin"); 597 | assert_eq!(password, Some("open sesame".to_string())); 598 | } 599 | 600 | #[test] 601 | #[allow(deprecated)] 602 | fn parses_bearer_auth_correctly() { 603 | let auth = header::Authorization(Bearer { 604 | token: "token".to_string(), 605 | }); 606 | let header = HeaderFormatter(&auth).to_string(); 607 | let parsed_header = not_err!(Authorization::new(&header)); 608 | let Authorization(header::Authorization(Bearer { token })) = parsed_header; 609 | assert_eq!(token, "token"); 610 | } 611 | 612 | #[test] 613 | #[allow(deprecated)] 614 | fn parses_string_auth_correctly() { 615 | let auth = header::Authorization("hello".to_string()); 616 | let header = HeaderFormatter(&auth).to_string(); 617 | let parsed_header: Authorization = not_err!(Authorization::new(&header)); 618 | let Authorization(header::Authorization(ref s)) = parsed_header; 619 | assert_eq!(s, "hello"); 620 | } 621 | 622 | #[test] 623 | #[allow(deprecated)] 624 | fn mock_basic_auth_get_test() { 625 | let rocket = ignite_basic(Box::new(MockAuthenticator {})); 626 | let client = not_err!(Client::new(rocket)); 627 | 628 | // Make headers 629 | let auth_header = hyper::header::Authorization(Basic { 630 | username: "mei".to_owned(), 631 | password: Some("冻住,不许走!".to_string()), 632 | }); 633 | let auth_header = 634 | http::Header::new("Authorization", HeaderFormatter(&auth_header).to_string()); 635 | // Make and dispatch request 636 | let req = client.get("/").header(auth_header); 637 | let response = req.dispatch(); 638 | 639 | // Assert 640 | assert_eq!(response.status(), Status::Ok); 641 | } 642 | 643 | #[test] 644 | #[allow(deprecated)] 645 | fn mock_bearer_auth_get_test() { 646 | let rocket = ignite_bearer(Box::new(MockAuthenticator {})); 647 | let client = not_err!(Client::new(rocket)); 648 | 649 | // Make headers 650 | let auth_header = hyper::header::Authorization(Bearer { 651 | token: "这样可以挡住他们。".to_string(), 652 | }); 653 | let auth_header = 654 | http::Header::new("Authorization", HeaderFormatter(&auth_header).to_string()); 655 | // Make and dispatch request 656 | let req = client.get("/").header(auth_header); 657 | let response = req.dispatch(); 658 | 659 | // Assert 660 | assert_eq!(response.status(), Status::Ok); 661 | } 662 | 663 | #[test] 664 | #[allow(deprecated)] 665 | fn mock_string_auth_get_test() { 666 | let rocket = ignite_string(Box::new(MockAuthenticator {})); 667 | let client = not_err!(Client::new(rocket)); 668 | 669 | // Make headers 670 | let auth_header = hyper::header::Authorization("哦,对不起啦。".to_string()); 671 | let auth_header = 672 | http::Header::new("Authorization", HeaderFormatter(&auth_header).to_string()); 673 | // Make and dispatch request 674 | let req = client.get("/").header(auth_header); 675 | let response = req.dispatch(); 676 | 677 | // Assert 678 | assert_eq!(response.status(), Status::Ok); 679 | } 680 | 681 | #[test] 682 | #[allow(deprecated)] 683 | fn mock_basic_auth_get_invalid_credentials() { 684 | // Ignite rocket 685 | let rocket = ignite_basic(Box::new(MockAuthenticator {})); 686 | let client = not_err!(Client::new(rocket)); 687 | 688 | // Make headers 689 | let auth_header = hyper::header::Authorization(Basic { 690 | username: "Aladin".to_owned(), 691 | password: Some("let me in".to_string()), 692 | }); 693 | let auth_header = 694 | http::Header::new("Authorization", HeaderFormatter(&auth_header).to_string()); 695 | // Make and dispatch request 696 | let req = client.get("/").header(auth_header); 697 | let response = req.dispatch(); 698 | 699 | // Assert 700 | assert_eq!(response.status(), Status::Unauthorized); 701 | } 702 | 703 | #[test] 704 | #[allow(deprecated)] 705 | fn mock_bearer_auth_get_invalid_credentials() { 706 | // Ignite rocket 707 | let rocket = ignite_bearer(Box::new(MockAuthenticator {})); 708 | let client = not_err!(Client::new(rocket)); 709 | 710 | // Make headers 711 | let auth_header = hyper::header::Authorization(Bearer { 712 | token: "bad".to_string(), 713 | }); 714 | let auth_header = 715 | http::Header::new("Authorization", HeaderFormatter(&auth_header).to_string()); 716 | // Make and dispatch request 717 | let req = client.get("/").header(auth_header); 718 | let response = req.dispatch(); 719 | 720 | // Assert 721 | assert_eq!(response.status(), Status::Unauthorized); 722 | } 723 | 724 | #[test] 725 | #[allow(deprecated)] 726 | fn mock_string_auth_get_invalid_credentials() { 727 | // Ignite rocket 728 | let rocket = ignite_string(Box::new(MockAuthenticator {})); 729 | let client = not_err!(Client::new(rocket)); 730 | 731 | // Make headers 732 | let auth_header = hyper::header::Authorization("bad".to_string()); 733 | let auth_header = 734 | http::Header::new("Authorization", HeaderFormatter(&auth_header).to_string()); 735 | // Make and dispatch request 736 | let req = client.get("/").header(auth_header); 737 | let response = req.dispatch(); 738 | 739 | // Assert 740 | assert_eq!(response.status(), Status::Unauthorized); 741 | } 742 | 743 | #[test] 744 | #[allow(deprecated)] 745 | fn mock_basic_auth_get_missing_credentials() { 746 | // Ignite rocket 747 | let rocket = ignite_basic(Box::new(MockAuthenticator {})); 748 | let client = not_err!(Client::new(rocket)); 749 | 750 | // Make and dispatch request 751 | let req = client.get("/"); 752 | let response = req.dispatch(); 753 | 754 | // Assert 755 | assert_eq!(response.status(), Status::Unauthorized); 756 | 757 | let www_header: Vec<_> = response.headers().get("WWW-Authenticate").collect(); 758 | assert_eq!(www_header, vec!["Basic realm=https://www.acme.com"]); 759 | } 760 | } 761 | -------------------------------------------------------------------------------- /src/auth/noop.rs: -------------------------------------------------------------------------------- 1 | //! A "no-op" authenticator that lets everything through 2 | use hyper::header::{self, Header}; 3 | 4 | use super::{ 5 | AuthenticationResult, Authenticator, AuthenticatorConfiguration, Authorization, Basic, Bearer, 6 | }; 7 | use crate::{Error, JsonMap, JsonValue}; 8 | 9 | /// A "no-op" authenticator that lets everything through. _DO NOT USE THIS IN PRODUCTION_. 10 | #[derive(Debug)] 11 | pub struct NoOp {} 12 | 13 | impl NoOp { 14 | #[allow(deprecated)] 15 | fn format(authorization: &header::Authorization) -> String { 16 | header::HeaderFormatter(authorization).to_string() 17 | } 18 | 19 | /// Generate a refresh token payload from the header 20 | fn serialize_refresh_token_payload( 21 | authorization: &header::Authorization, 22 | ) -> JsonValue { 23 | let string = From::from(Self::format(authorization)); 24 | let mut map = JsonMap::with_capacity(1); 25 | let _ = map.insert("header".to_string(), string); 26 | JsonValue::Object(map) 27 | } 28 | 29 | /// From a refresh token payload, retrieve the headr 30 | fn deserialize_refresh_token_payload( 31 | refresh_payload: &JsonValue, 32 | ) -> Result, Error> { 33 | match *refresh_payload { 34 | JsonValue::Object(ref map) => { 35 | let header = map 36 | .get("header") 37 | .ok_or_else(|| Error::Auth(super::Error::AuthenticationFailure))? 38 | .as_str() 39 | .ok_or_else(|| Error::Auth(super::Error::AuthenticationFailure))?; 40 | let header = header.as_bytes().to_vec(); 41 | let header: header::Authorization = 42 | header::Authorization::parse_header(&[header]) 43 | .map_err(|_| Error::Auth(super::Error::AuthenticationFailure))?; 44 | Ok(header) 45 | } 46 | _ => Err(Error::Auth(super::Error::AuthenticationFailure)), 47 | } 48 | } 49 | } 50 | 51 | impl Authenticator for NoOp { 52 | fn authenticate( 53 | &self, 54 | authorization: &Authorization, 55 | include_refresh_payload: bool, 56 | ) -> Result { 57 | warn_!("Do not use the NoOp authenticator in production"); 58 | let refresh_payload = if include_refresh_payload { 59 | Some(Self::serialize_refresh_token_payload(authorization)) 60 | } else { 61 | None 62 | }; 63 | Ok(AuthenticationResult { 64 | subject: authorization.username(), 65 | private_claims: JsonValue::Object(JsonMap::new()), 66 | refresh_payload, 67 | }) 68 | } 69 | 70 | fn authenticate_refresh_token( 71 | &self, 72 | refresh_payload: &JsonValue, 73 | ) -> Result { 74 | warn_!("Do not use the NoOp authenticator in production"); 75 | let header: header::Authorization = 76 | Self::deserialize_refresh_token_payload(refresh_payload)?; 77 | self.authenticate(&Authorization(header), false) 78 | } 79 | } 80 | 81 | impl Authenticator for NoOp { 82 | fn authenticate( 83 | &self, 84 | authorization: &Authorization, 85 | include_refresh_payload: bool, 86 | ) -> Result { 87 | warn_!("Do not use the NoOp authenticator in production"); 88 | let refresh_payload = if include_refresh_payload { 89 | Some(Self::serialize_refresh_token_payload(authorization)) 90 | } else { 91 | None 92 | }; 93 | Ok(AuthenticationResult { 94 | subject: authorization.token(), 95 | private_claims: JsonValue::Object(JsonMap::new()), 96 | refresh_payload, 97 | }) 98 | } 99 | 100 | fn authenticate_refresh_token( 101 | &self, 102 | refresh_payload: &JsonValue, 103 | ) -> Result { 104 | warn_!("Do not use the NoOp authenticator in production"); 105 | let header: header::Authorization = 106 | Self::deserialize_refresh_token_payload(refresh_payload)?; 107 | self.authenticate(&Authorization(header), false) 108 | } 109 | } 110 | 111 | impl Authenticator for NoOp { 112 | fn authenticate( 113 | &self, 114 | authorization: &Authorization, 115 | include_refresh_payload: bool, 116 | ) -> Result { 117 | warn_!("Do not use the NoOp authenticator in production"); 118 | let refresh_payload = if include_refresh_payload { 119 | Some(Self::serialize_refresh_token_payload(authorization)) 120 | } else { 121 | None 122 | }; 123 | Ok(AuthenticationResult { 124 | subject: authorization.string(), 125 | private_claims: JsonValue::Object(JsonMap::new()), 126 | refresh_payload, 127 | }) 128 | } 129 | 130 | fn authenticate_refresh_token( 131 | &self, 132 | refresh_payload: &JsonValue, 133 | ) -> Result { 134 | warn_!("Do not use the NoOp authenticator in production"); 135 | let header: header::Authorization = 136 | Self::deserialize_refresh_token_payload(refresh_payload)?; 137 | self.authenticate(&Authorization(header), false) 138 | } 139 | } 140 | 141 | /// Configuration for the `no-op` authenticator. Nothing to configure. 142 | #[derive(Serialize, Deserialize, Debug)] 143 | pub struct NoOpConfiguration {} 144 | 145 | impl AuthenticatorConfiguration for NoOpConfiguration 146 | where 147 | NoOp: Authenticator, 148 | { 149 | type Authenticator = NoOp; 150 | 151 | fn make_authenticator(&self) -> Result { 152 | Ok(Self::Authenticator {}) 153 | } 154 | } 155 | 156 | #[cfg(test)] 157 | pub mod tests { 158 | use hyper; 159 | use rocket::http::{self, Status}; 160 | use rocket::local::Client; 161 | 162 | use super::super::tests::{ignite_basic, ignite_bearer, ignite_string}; 163 | use super::*; 164 | use crate::auth::Authenticator; 165 | 166 | #[test] 167 | fn authentication() { 168 | let authenticator = NoOp {}; 169 | 170 | // Basic 171 | let auth_header = hyper::header::Authorization(Basic { 172 | username: "anything".to_owned(), 173 | password: Some("let me in".to_string()), 174 | }); 175 | let result = not_err!(authenticator.authenticate(&Authorization(auth_header), false,)); 176 | assert!(result.refresh_payload.is_none()); 177 | 178 | // Bearer 179 | let auth_header = hyper::header::Authorization(Bearer { 180 | token: "foobar".to_string(), 181 | }); 182 | let result = not_err!(authenticator.authenticate(&Authorization(auth_header), false,)); 183 | assert!(result.refresh_payload.is_none()); 184 | 185 | // String 186 | let auth_header = hyper::header::Authorization("anything goes".to_string()); 187 | let result = not_err!(authenticator.authenticate(&Authorization(auth_header), false,)); 188 | assert!(result.refresh_payload.is_none()); 189 | } 190 | 191 | #[test] 192 | fn authentication_with_refresh_token() { 193 | let authenticator = NoOp {}; 194 | 195 | // Basic 196 | let auth_header = hyper::header::Authorization(Basic { 197 | username: "anything".to_owned(), 198 | password: Some("let me in".to_string()), 199 | }); 200 | let result = not_err!(authenticator.authenticate(&Authorization(auth_header), true,)); 201 | assert!(result.refresh_payload.is_some()); // should include a refresh token 202 | let result = not_err!(Authenticator::::authenticate_refresh_token( 203 | &authenticator, 204 | result.refresh_payload.as_ref().unwrap(), 205 | )); 206 | assert!(result.refresh_payload.is_none()); // should NOT include a refresh token 207 | 208 | // Bearer 209 | let auth_header = hyper::header::Authorization(Bearer { 210 | token: "foobar".to_string(), 211 | }); 212 | let result = not_err!(authenticator.authenticate(&Authorization(auth_header), true,)); 213 | assert!(result.refresh_payload.is_some()); // should include a refresh token 214 | let result = not_err!(Authenticator::::authenticate_refresh_token( 215 | &authenticator, 216 | result.refresh_payload.as_ref().unwrap(), 217 | )); 218 | assert!(result.refresh_payload.is_none()); // should NOT include a refresh token 219 | 220 | // String 221 | let auth_header = hyper::header::Authorization("anything goes".to_string()); 222 | let result = not_err!(authenticator.authenticate(&Authorization(auth_header), true,)); 223 | assert!(result.refresh_payload.is_some()); // should include a refresh token 224 | let result = not_err!(Authenticator::::authenticate_refresh_token( 225 | &authenticator, 226 | result.refresh_payload.as_ref().unwrap(), 227 | )); 228 | assert!(result.refresh_payload.is_none()); // should NOT include a refresh token 229 | } 230 | 231 | #[test] 232 | #[allow(deprecated)] 233 | fn noop_basic_auth_get_test() { 234 | let rocket = ignite_basic(Box::new(NoOp {})); 235 | let client = not_err!(Client::new(rocket)); 236 | 237 | // Make headers 238 | let auth_header = hyper::header::Authorization(Basic { 239 | username: "anything".to_owned(), 240 | password: Some("let me in".to_string()), 241 | }); 242 | let auth_header = http::Header::new( 243 | "Authorization", 244 | hyper::header::HeaderFormatter(&auth_header).to_string(), 245 | ); 246 | // Make and dispatch request 247 | let req = client.get("/").header(auth_header); 248 | let response = req.dispatch(); 249 | 250 | // Assert 251 | assert_eq!(response.status(), Status::Ok); 252 | } 253 | 254 | #[test] 255 | #[allow(deprecated)] 256 | fn noop_bearer_auth_get_test() { 257 | let rocket = ignite_bearer(Box::new(NoOp {})); 258 | let client = not_err!(Client::new(rocket)); 259 | 260 | // Make headers 261 | let auth_header = hyper::header::Authorization(Bearer { 262 | token: "foobar".to_string(), 263 | }); 264 | let auth_header = http::Header::new( 265 | "Authorization", 266 | hyper::header::HeaderFormatter(&auth_header).to_string(), 267 | ); 268 | // Make and dispatch request 269 | let req = client.get("/").header(auth_header); 270 | let response = req.dispatch(); 271 | 272 | // Assert 273 | assert_eq!(response.status(), Status::Ok); 274 | } 275 | 276 | #[test] 277 | #[allow(deprecated)] 278 | fn noop_string_auth_get_test() { 279 | let rocket = ignite_string(Box::new(NoOp {})); 280 | let client = not_err!(Client::new(rocket)); 281 | 282 | // Make headers 283 | let auth_header = hyper::header::Authorization("anything goes".to_string()); 284 | let auth_header = http::Header::new( 285 | "Authorization", 286 | hyper::header::HeaderFormatter(&auth_header).to_string(), 287 | ); 288 | // Make and dispatch request 289 | let req = client.get("/").header(auth_header); 290 | let response = req.dispatch(); 291 | 292 | // Assert 293 | assert_eq!(response.status(), Status::Ok); 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/auth/simple.rs: -------------------------------------------------------------------------------- 1 | //! Simple authenticator module 2 | use std::collections::HashMap; 3 | use std::io::{Read, Write}; 4 | 5 | use csv; 6 | // FIXME: Remove dependency on `ring`. 7 | use ring::constant_time::verify_slices_are_equal; 8 | use ring::test; 9 | 10 | use super::util::{generate_salt, hash_password_digest, hex_dump}; 11 | use super::{AuthenticationResult, Basic}; 12 | use crate::{Error, JsonMap, JsonValue}; 13 | 14 | // Code for conversion to hex stolen from rustc-serialize: 15 | // https://doc.rust-lang.org/rustc-serialize/src/rustc_serialize/hex.rs.html 16 | 17 | /// Typedef for the internal representation of a users database. The keys are the usernames, 18 | /// and the values are a tuple of the password hash and salt. 19 | pub type Users = HashMap, Vec)>; 20 | 21 | /// A simple authenticator that uses a CSV backed user database. _DO NOT USE THIS IN PRODUCTION_ 22 | /// 23 | /// Requires the `simple_authenticator` feature, which is enabled by default. 24 | /// 25 | /// The user database should be a CSV file, or a "CSV-like" file 26 | /// (meaning you can choose to use some other character as field delimiter instead of comma) 27 | /// where the first column is the username, the second column is a hashed password, and 28 | /// the third column is the salt. 29 | /// 30 | /// # Password Hashing 31 | /// See `SimpleAuthenticator::hash_password` for the implementation of password hashing. 32 | /// The password is hashed using the [`argon2i`](https://github.com/p-h-c/phc-winner-argon2) 33 | /// algorithm with a randomly generated salt. 34 | pub struct SimpleAuthenticator { 35 | users: Users, 36 | } 37 | 38 | impl SimpleAuthenticator { 39 | /// Create a new `SimpleAuthenticator` with the provided a CSV Reader. 40 | /// 41 | pub fn new(csv: csv::Reader) -> Result { 42 | warn_!("Do not use the Simple authenticator in production"); 43 | Ok(SimpleAuthenticator { 44 | users: Self::users_from_csv(csv)?, 45 | }) 46 | } 47 | 48 | /// Create a new `SimpleAuthenticator` with a path to a CSV file. 49 | /// 50 | pub fn with_csv_file(path: &str, has_headers: bool, delimiter: u8) -> Result { 51 | let reader = csv::ReaderBuilder::new() 52 | .has_headers(has_headers) 53 | .delimiter(delimiter) 54 | .from_path(path) 55 | .map_err(|e| e.to_string())?; 56 | Self::new(reader) 57 | } 58 | 59 | fn users_from_csv(mut csv: csv::Reader) -> Result { 60 | // Parse the records, and look for errors 61 | let records: Vec> = csv.deserialize().collect(); 62 | let (records, errors): (Vec<_>, Vec<_>) = records.into_iter().partition(Result::is_ok); 63 | if !errors.is_empty() { 64 | let errors: Vec = errors 65 | .into_iter() 66 | .map(|r| r.unwrap_err().to_string()) 67 | .collect(); 68 | Err(errors.join("; "))?; 69 | } 70 | 71 | type ParsedRecordBytes = Vec, Vec), String>>; 72 | // Decode the hex values from users 73 | let (users, errors): (ParsedRecordBytes, ParsedRecordBytes) = records 74 | .into_iter() 75 | .map(|r| { 76 | let (username, hash, salt) = r.unwrap(); // safe to unwrap 77 | let hash = test::from_hex(&hash)?; 78 | let salt = test::from_hex(&salt)?; 79 | Ok((username, hash, salt)) 80 | }) 81 | .partition(Result::is_ok); 82 | 83 | if !errors.is_empty() { 84 | let errors: Vec = errors.into_iter().map(|r| r.unwrap_err()).collect(); 85 | Err(errors.join("; "))?; 86 | } 87 | 88 | let users: Users = users 89 | .into_iter() 90 | .map(|r| { 91 | let (username, hash, salt) = r.unwrap(); // safe to unwrap 92 | (username, (hash, salt)) 93 | }) 94 | .collect(); 95 | 96 | Ok(users) 97 | } 98 | 99 | /// Hash a password with the salt. See struct level documentation for the algorithm used. 100 | // TODO: Write an "example" tool to salt easily 101 | pub fn hash_password(password: &str, salt: &[u8]) -> Result { 102 | Ok(hex_dump(hash_password_digest(password, salt).as_ref())) 103 | } 104 | 105 | /// Verify that some user with the provided password exists in the CSV database, 106 | /// and the password is correct. 107 | /// 108 | /// Returns the payload to be included in a refresh token if successful 109 | pub fn verify( 110 | &self, 111 | username: &str, 112 | password: &str, 113 | include_refresh_payload: bool, 114 | ) -> Result { 115 | match self.users.get(username) { 116 | None => Err(Error::Auth(super::Error::AuthenticationFailure)), 117 | Some(&(ref hash, ref salt)) => { 118 | let actual_password_digest = hash_password_digest(password, salt); 119 | if !verify_slices_are_equal(actual_password_digest.as_ref(), &*hash).is_ok() { 120 | Err(Error::Auth(super::Error::AuthenticationFailure)) 121 | } else { 122 | let refresh_payload = if include_refresh_payload { 123 | let mut map = JsonMap::with_capacity(2); 124 | let _ = map.insert("user".to_string(), From::from(username)); 125 | let _ = map.insert("password".to_string(), From::from(password)); 126 | Some(JsonValue::Object(map)) 127 | } else { 128 | None 129 | }; 130 | 131 | Ok(AuthenticationResult { 132 | subject: username.to_string(), 133 | private_claims: JsonValue::Object(JsonMap::new()), 134 | refresh_payload, 135 | }) 136 | } 137 | } 138 | } 139 | } 140 | } 141 | 142 | impl super::Authenticator for SimpleAuthenticator { 143 | fn authenticate( 144 | &self, 145 | authorization: &super::Authorization, 146 | include_refresh_payload: bool, 147 | ) -> Result { 148 | warn_!("Do not use the Simple authenticator in production"); 149 | let username = authorization.username(); 150 | let password = authorization.password().unwrap_or_else(|| "".to_string()); 151 | self.verify(&username, &password, include_refresh_payload) 152 | } 153 | 154 | fn authenticate_refresh_token( 155 | &self, 156 | refresh_payload: &JsonValue, 157 | ) -> Result { 158 | warn_!("Do not use the Simple authenticator in production"); 159 | match *refresh_payload { 160 | JsonValue::Object(ref map) => { 161 | let user = map 162 | .get("user") 163 | .ok_or_else(|| super::Error::AuthenticationFailure)? 164 | .as_str() 165 | .ok_or_else(|| super::Error::AuthenticationFailure)?; 166 | let password = map 167 | .get("password") 168 | .ok_or_else(|| super::Error::AuthenticationFailure)? 169 | .as_str() 170 | .ok_or_else(|| super::Error::AuthenticationFailure)?; 171 | self.verify(user, password, false) 172 | } 173 | _ => Err(super::Error::AuthenticationFailure)?, 174 | } 175 | } 176 | } 177 | 178 | /// (De)Serializable configuration for `SimpleAuthenticator`. This struct should be included 179 | /// in the base `Configuration`. 180 | /// # Examples 181 | /// ```json 182 | /// { 183 | /// "csv_path": "test/fixtures/users.csv", 184 | /// "has_headers": false, 185 | /// "delimiter": " " 186 | /// } 187 | /// ``` 188 | #[derive(Eq, PartialEq, Serialize, Deserialize, Debug)] 189 | pub struct SimpleAuthenticatorConfiguration { 190 | /// Path to the CSV database, in the format described by `SimpleAuthenticator`. This should be 191 | /// relative to the working directory, or an absolute path 192 | pub csv_path: String, 193 | /// Whether the CSV file has a header row or not. Defaults to `false`. 194 | #[serde(default)] 195 | pub has_headers: bool, 196 | /// The delimiter char. By default, this is ','. 197 | /// Because of the limitation of the `CSV` crate which elects to only allow delimiters with 198 | /// one byte, the `char` will be truncated to only one byte. 199 | /// This means you should only use delimiters that are ASCII. 200 | #[serde(default = "default_delimiter")] 201 | pub delimiter: char, 202 | } 203 | 204 | fn default_delimiter() -> char { 205 | ',' 206 | } 207 | 208 | impl super::AuthenticatorConfiguration for SimpleAuthenticatorConfiguration { 209 | type Authenticator = SimpleAuthenticator; 210 | 211 | fn make_authenticator(&self) -> Result { 212 | Ok(SimpleAuthenticator::with_csv_file( 213 | &self.csv_path, 214 | self.has_headers, 215 | self.delimiter as u8, 216 | )?) 217 | } 218 | } 219 | 220 | /// Convenience function to hash passwords from some users and provided passwords 221 | /// The salt length must be between 8 and 2^32 - 1 bytes. 222 | pub fn hash_passwords(users: &HashMap, salt_len: usize) -> Result { 223 | let mut hashed: Users = HashMap::new(); 224 | for (user, password) in users { 225 | let salt = generate_salt(salt_len).map_err(|()| "Unspecified error".to_string())?; 226 | let hash = hash_password_digest(password, &salt); 227 | let _ = hashed.insert(user.to_string(), (hash, salt)); 228 | } 229 | Ok(hashed) 230 | } 231 | 232 | /// Convenience function to write `Users` to a Writer 233 | pub fn write_csv(users: &Users, mut writer: W) -> Result<(), Error> { 234 | for (username, &(ref hash, ref salt)) in users { 235 | let record = vec![username.to_string(), hex_dump(hash), hex_dump(salt)].join(","); 236 | writer.write_all(record.as_bytes())?; 237 | writer.write_all(b"\n")?; 238 | } 239 | Ok(()) 240 | } 241 | 242 | #[cfg(test)] 243 | mod tests { 244 | use super::*; 245 | use crate::auth::Authenticator; 246 | 247 | fn make_authenticator() -> SimpleAuthenticator { 248 | not_err!(SimpleAuthenticator::with_csv_file( 249 | "test/fixtures/users.csv", 250 | false, 251 | b',', 252 | )) 253 | } 254 | 255 | #[test] 256 | fn test_hex_dump() { 257 | assert_eq!(hex_dump(b"foobar"), "666f6f626172"); 258 | } 259 | 260 | #[test] 261 | fn test_hex_dump_all_bytes() { 262 | for i in 0..256 { 263 | assert_eq!(hex_dump(&[i as u8]), format!("{:02x}", i)); 264 | } 265 | } 266 | 267 | #[test] 268 | fn hashing_is_done_correctly() { 269 | let hashed_password = not_err!(SimpleAuthenticator::hash_password("password", &[0; 32])); 270 | assert_eq!( 271 | "e6e1111452a5574d8d64f6f4ba6fabc86af5c45c341df1eb23026373c41d24b8", 272 | hashed_password 273 | ); 274 | } 275 | 276 | #[test] 277 | fn hashing_is_done_correctly_for_unicode() { 278 | let hashed_password = not_err!(SimpleAuthenticator::hash_password( 279 | "冻住,不许走!", 280 | &[0; 32], 281 | )); 282 | assert_eq!( 283 | "b400a5eea452afcc67a81602f28012e5634404ddf1e043d6ff1df67022c88cd2", 284 | hashed_password 285 | ); 286 | } 287 | 288 | #[test] 289 | fn csv_generation_round_trip() { 290 | use std::io::Cursor; 291 | 292 | let users: HashMap = 293 | [("foobar", "password"), ("mei", "冻住,不许走!")] 294 | .into_iter() 295 | .map(|&(u, p)| (u.to_string(), p.to_string())) 296 | .collect(); 297 | let users = not_err!(hash_passwords(&users, 32)); 298 | 299 | let mut cursor: Cursor> = Cursor::new(vec![]); 300 | not_err!(write_csv(&users, &mut cursor)); 301 | 302 | cursor.set_position(0); 303 | let authenticator = not_err!(SimpleAuthenticator::new( 304 | csv::ReaderBuilder::new() 305 | .has_headers(false,) 306 | .from_reader(&mut cursor,), 307 | )); 308 | 309 | let expected_keys = vec!["foobar".to_string(), "mei".to_string()]; 310 | let mut actual_keys: Vec = authenticator.users.keys().cloned().collect(); 311 | actual_keys.sort(); 312 | assert_eq!(expected_keys, actual_keys); 313 | 314 | let _ = not_err!(authenticator.verify("foobar", "password", false)); 315 | 316 | let result = not_err!(authenticator.verify("mei", "冻住,不许走!", false)); 317 | // refresh refresh_payload is not provided when not requested 318 | assert!(result.refresh_payload.is_none()); 319 | } 320 | 321 | #[test] 322 | fn authentication_with_username_and_password() { 323 | let authenticator = make_authenticator(); 324 | let expected_keys = vec!["foobar".to_string(), "mei".to_string()]; 325 | let mut actual_keys: Vec = authenticator.users.keys().cloned().collect(); 326 | actual_keys.sort(); 327 | assert_eq!(expected_keys, actual_keys); 328 | 329 | let _ = not_err!(authenticator.verify("foobar", "password", false)); 330 | 331 | let result = not_err!(authenticator.verify("mei", "冻住,不许走!", false)); 332 | // refresh refresh_payload is not provided when not requested 333 | assert!(result.refresh_payload.is_none()); 334 | } 335 | 336 | #[test] 337 | fn authentication_with_refresh_payload() { 338 | let authenticator = make_authenticator(); 339 | 340 | let result = not_err!(authenticator.verify("foobar", "password", true)); 341 | // refresh refresh_payload is provided when requested 342 | assert!(result.refresh_payload.is_some()); 343 | 344 | let result = not_err!( 345 | authenticator.authenticate_refresh_token(result.refresh_payload.as_ref().unwrap(),) 346 | ); 347 | assert!(result.refresh_payload.is_none()); 348 | } 349 | 350 | #[test] 351 | fn simple_authenticator_configuration_deserialization() { 352 | use crate::auth::AuthenticatorConfiguration; 353 | use serde_json; 354 | 355 | let json = r#"{ 356 | "csv_path": "test/fixtures/users.csv", 357 | "delimiter": "," 358 | }"#; 359 | 360 | let deserialized: SimpleAuthenticatorConfiguration = not_err!(serde_json::from_str(json)); 361 | let expected_config = SimpleAuthenticatorConfiguration { 362 | csv_path: "test/fixtures/users.csv".to_string(), 363 | has_headers: false, 364 | delimiter: ',', 365 | }; 366 | assert_eq!(deserialized, expected_config); 367 | 368 | let _ = not_err!(expected_config.make_authenticator()); 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /src/auth/util.rs: -------------------------------------------------------------------------------- 1 | //! Utility functions to aid in managing users 2 | //! 3 | //! Primarily, this module provides a useful function to hash a password, in combination with a 4 | //! salt to produce a hash with [Argon2i](https://en.wikipedia.org/wiki/Argon2). 5 | //! 6 | //! The hash produced will be 32 bytes long. 7 | use crate::jwt::jwa::SecureRandom; 8 | use argon2rs; 9 | 10 | static CHARS: &'static [u8] = b"0123456789abcdef"; 11 | 12 | /// Given a password and a salt, generate an argon2i hash 32 bytes in length 13 | /// 14 | /// Note that a salt between 8 and 2^32-1 bytes must be provided. 15 | pub fn hash_password_digest(password: &str, salt: &[u8]) -> Vec { 16 | let bytes = password.as_bytes(); 17 | let mut out = vec![0; argon2rs::defaults::LENGTH]; // 32 bytes 18 | let argon2 = argon2rs::Argon2::default(argon2rs::Variant::Argon2i); 19 | argon2.hash(&mut out, bytes, salt, &[], &[]); 20 | out 21 | } 22 | 23 | /// Generate a new random salt based on the configured salt length 24 | /// 25 | /// For argon2i, you should use a salt between 8 and 2^32-1 bytes 26 | /// 27 | /// If this function fails, no extra details can be provided. 28 | /// See [`Unspecified`](https://briansmith.org/rustdoc/ring/error/struct.Unspecified.html) 29 | pub fn generate_salt(salt_length: usize) -> Result, ()> { 30 | let mut salt: Vec = vec![0; salt_length]; 31 | crate::rng().fill(&mut salt).map_err(|_| ())?; 32 | Ok(salt) 33 | } 34 | 35 | /// Dump a bunch of bytes as a hexadecimal string 36 | pub fn hex_dump(bytes: &[u8]) -> String { 37 | let mut v = Vec::with_capacity(bytes.len() * 2); 38 | for &byte in bytes.iter() { 39 | v.push(CHARS[(byte >> 4) as usize]); 40 | v.push(CHARS[(byte & 0xf) as usize]); 41 | } 42 | 43 | unsafe { String::from_utf8_unchecked(v) } 44 | } 45 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! [![Build Status](https://travis-ci.org/lawliet89/rowdy.svg)](https://travis-ci.org/lawliet89/rowdy) 2 | //! [![Dependency Status](https://dependencyci.com/github/lawliet89/rowdy/badge)](https://dependencyci.com/github/lawliet89/rowdy) 3 | //! [![Crates.io](https://img.shields.io/crates/v/rowdy.svg)](https://crates.io/crates/rowdy) 4 | //! [![Repository](https://img.shields.io/github/tag/lawliet89/rowdy.svg)](https://github.com/lawliet89/rowdy) 5 | //! [![Documentation](https://docs.rs/rowdy/badge.svg)](https://docs.rs/rowdy) 6 | //! 7 | //! Documentation: [Stable](https://docs.rs/rowdy) | [Master](https://lawliet89.github.io/rowdy/) 8 | //! 9 | //! `rowdy` is a [Rocket](https://rocket.rs/) based JSON Web token based authentication server 10 | //! based off Docker Registry's 11 | //! [authentication protocol](https://docs.docker.com/registry/spec/auth/). 12 | //! 13 | //! # Features 14 | //! 15 | //! - `simple_authenticator`: A simple CSV based authenticator 16 | //! - `ldap_authenticator`: An LDAP based authenticator 17 | //! 18 | //! By default, the `simple_authenticator` feature is turned on. 19 | //! 20 | //! # `rowdy` Authentication Flow 21 | //! 22 | //! The authentication flow is inspired by 23 | //! [Docker Registry](https://docs.docker.com/registry/spec/auth/) authentication specification. 24 | //! 25 | //! ## JSON Web Tokens 26 | //! 27 | //! Authentication makes use of two types of [JSON Web Tokens (JWT)](https://jwt.io/): 28 | //! Access and Refresh tokens. 29 | //! 30 | //! ### Access Token 31 | //! 32 | //! The access token is a short lived JWT that allows users to access resources within the scope 33 | //! that they are allowed to. The access token itself contains enough information for services 34 | //! to verify the user and their permissions in a stateless manner. 35 | //! 36 | //! ### Refresh Token 37 | //! 38 | //! The refresh token allows users to retrieve a new access token without needing to 39 | //! re-authenticate. As such, the refresh token is longer lived, but can be revoked. 40 | //! 41 | //! ## Authentication Flow 42 | //! 43 | //! 1. Client attempts to access a resource on a protected service. 44 | //! 1. Service responds with a `401 Unauthorized` authentication challenge with information on 45 | //! how to authenticate 46 | //! provided in the `WWW-Authenticate` response header. 47 | //! 1. Using the information from the previous step, the client authenticates with the 48 | //! authentication server. The client 49 | //! will receive, among other information, opaque access and refresh tokens. 50 | //! 1. The client retries the original request with the Bearer token embedded in the request’s 51 | //! Authorization header. 52 | //! 1. The service authorizes the client by validating the Bearer token and the claim set 53 | //! embedded within it and 54 | //! proceeds as usual. 55 | //! 56 | //! ### Authentication Challenge 57 | //! 58 | //! Services will challenge users who do not provide a valid token via the HTTP response 59 | //! `401 Unauthorized`. Details for 60 | //! authentication is provided in the `WWW-Authenticate` header. 61 | //! 62 | //! ```text 63 | //! Www-Authenticate: Bearer realm="https://www.auth.com",service="https://www.example.com",scope="all" 64 | //! ``` 65 | //! 66 | //! The `realm` field indicates the authentcation server endpoint which clients should proceed to 67 | //! authenticate against. 68 | //! 69 | //! The `service` field indicates the `service` value that clients should use when attempting to 70 | //! authenticate at `realm`. 71 | //! 72 | //! The `scope` field indicates the `scope` value that clients should use when attempting to 73 | //! authenticate at `realm`. 74 | //! 75 | //! ### Retrieving an Access Token (and optionally Refresh Token) from the Authentication Server 76 | //! 77 | //! A HTTP `GET` request should be made to the `realm` endpoint provided above. The endpoint will 78 | //! support the following uery paremeters: 79 | //! 80 | //! - `service`: The service that the client is authenticating for. This should be the same as 81 | //! the `service` value in the previous step 82 | //! - `scope`: The scope that the client wishes to authenticate for. 83 | //! This should be the same as the `scope` value in the previous step. 84 | //! - `offline_token`: Set to `true` if a refresh token is also required. Defaults to `false`. 85 | //! Cannot be set to `true` when using a refresh token to retrieve a new access token. 86 | //! 87 | //! When authenticating for the first time, clients should send the user's username and passwords 88 | //! in the form of `Basic` authentication. If the client already has a prior refresh token and 89 | //! would like to obtain a new access token, the client should send the refresh token in the form 90 | //! of `Bearer` authentication. 91 | //! 92 | //! If successful, the authentcation server will return a `200 OK` response with a 93 | //! JSON body containing the following fields: 94 | //! 95 | //! - `token`: An opaque Access (`Bearer`) token that clients should supply to subsequent requests 96 | //! in the `Authorization` header. 97 | //! - `expires_in`: The duration in seconds since the token was issued that it will remain valid. 98 | //! - `issued_at`: RFC3339-serialized UTC standard time at which a given token was issued. 99 | //! - `refresh_token`: An opaque `Refresh` token which can be used to get additional access 100 | //! tokens for the same subject with different scopes. This token should be kept secure by 101 | //! the client and only sent to the authorization server which issues access tokens. 102 | //! This field will only be set when `offline_token=true` is provided in the request. 103 | //! 104 | //! If this fails, the server will return with the appropriate `4xx` response. 105 | //! 106 | //! ### Using the Access Token 107 | //! 108 | //! Once the client has a token, it will try the request again with the token placed in the 109 | //! HTTP Authorization header like so: 110 | //! 111 | //! ```text 112 | //! Authorization: Bearer 113 | //! ``` 114 | //! 115 | //! ### Using the Refresh Token to Retrieve a New Access Token 116 | //! 117 | //! When the client's Access token expires, and it has previously asked for a Refresh Token, 118 | //! the client can make a `GET` request to the same endpoint that the client used to retrieve the 119 | //! access token (the `realm` URL in an authentication challenge). 120 | //! 121 | //! The steps are described in the section "Retrieving an Access Token" above. The process is the 122 | //! same as the initial authentication except that instead of using `Basic` authentication, 123 | //! the client should instead send the refresh token retrieved prior as `Bearer` authentication. 124 | //! Also, `offline_token` cannot be requested for when requesting for a new access token using a 125 | //! refresh token. (HTTP 401 will be returned if this happens.) 126 | //! 127 | //! ### Example 128 | //! 129 | //! This example uses `curl` to make request to the some (hypothetical) protected endpoint. 130 | //! It requires [`jq`](https://stedolan.github.io/jq/) to parse JSON. 131 | //! 132 | //! ```bash 133 | //! PROTECTED_RESOURCE="https://www.example.com/protected/resource/" 134 | //! 135 | //! # Save the response headers of our first request to the endpoint to get the Www-Authenticate 136 | //! # header 137 | //! RESPONSE_HEADER=$(tempfile); 138 | //! curl --dump-header "${RESPONSE_HEADER}" "${PROTECTED_RESOURCE}" 139 | //! 140 | //! # Extract the realm, the service, and the scope from the Www-Authenticate header 141 | //! WWWAUTH=$(cat "${RESPONSE_HEADER}" | grep "Www-Authenticate") 142 | //! REALM=$(echo "${WWWAUTH}" | grep -o '\(realm\)="[^"]*"' | cut -d '"' -f 2) 143 | //! SERVICE=$(echo "${WWWAUTH}" | grep -o '\(service\)="[^"]*"' | cut -d '"' -f 2) 144 | //! SCOPE=$(echo "${WWWAUTH}" | grep -o '\(scope\)="[^"]*"' | cut -d '"' -f 2) 145 | //! 146 | //! # Build the URL to query the auth server 147 | //! AUTH_URL="${REALM}?service=${SERVICE}&scope=${SCOPE}&offline_token=true" 148 | //! 149 | //! # Query the auth server to get a token -- replace the username and password 150 | //! # below with the value from 1password 151 | //! TOKEN=$(curl -s --user "mozart:password" "${AUTH_URL}") 152 | //! 153 | //! # Get the access token from the JSON string: {"token": "...."} 154 | //! ACCESS_TOKEN=$(echo ${TOKEN} | jq .token | tr -d '"') 155 | //! 156 | //! # Query the resource again, but this time with a bearer token 157 | //! curl -v -H "Authorization: Bearer ${ACCESS_TOKEN}" "${PROTECTED_RESOURCE}" 158 | //! 159 | //! # Get the refresh token 160 | //! REFRESH_TOKEN=$(echo "${TOKEN}" | jq .refresh_token | tr -d '"') 161 | //! 162 | //! # Get a new access token 163 | //! NEW_TOKEN=$(curl --header "Authorization: Bearer ${REFRESH_TOKEN}" "${AUTH_URL}") 164 | //! 165 | //! # Parse the new access token 166 | //! NEW_ACCESS_TOKEN=$(echo "${TOKEN}" | jq .token | tr -d '"') 167 | //! 168 | //! # Query the resource again, but this time with a new access token 169 | //! curl -v -H "Authorization: Bearer ${NEW_ACCESS_TOKEN}" "${PROTECTED_RESOURCE}" 170 | //! ``` 171 | //! 172 | //! ## Scope 173 | //! 174 | //! Not in use at the moment. Just use `all`. 175 | //! 176 | #![feature(proc_macro_hygiene, decl_macro)] 177 | // See https://github.com/rust-unofficial/patterns/blob/master/anti_patterns/deny-warnings.md 178 | #![allow( 179 | legacy_directory_ownership, 180 | missing_copy_implementations, 181 | missing_debug_implementations, 182 | unknown_lints, 183 | unsafe_code, 184 | intra_doc_link_resolution_failure 185 | )] 186 | #![deny( 187 | const_err, 188 | dead_code, 189 | deprecated, 190 | exceeding_bitshifts, 191 | improper_ctypes, 192 | missing_docs, 193 | mutable_transmutes, 194 | no_mangle_const_items, 195 | non_camel_case_types, 196 | non_shorthand_field_patterns, 197 | non_upper_case_globals, 198 | overflowing_literals, 199 | path_statements, 200 | plugin_as_library, 201 | stable_features, 202 | trivial_casts, 203 | trivial_numeric_casts, 204 | unconditional_recursion, 205 | unknown_crate_types, 206 | unreachable_code, 207 | unused_allocation, 208 | unused_assignments, 209 | unused_attributes, 210 | unused_comparisons, 211 | unused_extern_crates, 212 | unused_features, 213 | unused_imports, 214 | unused_import_braces, 215 | unused_qualifications, 216 | unused_must_use, 217 | unused_mut, 218 | unused_parens, 219 | unused_results, 220 | unused_unsafe, 221 | unused_variables, 222 | variant_size_differences, 223 | warnings, 224 | while_true 225 | )] 226 | #![doc(test(attr(allow(unused_variables), deny(warnings))))] 227 | 228 | use biscuit as jwt; 229 | 230 | use hyper; 231 | #[macro_use] 232 | extern crate lazy_static; 233 | #[macro_use] 234 | extern crate log; 235 | #[macro_use] 236 | extern crate rocket; 237 | // we are using the "log_!" macros which are redefined from `log`'s 238 | use rocket_cors as cors; 239 | 240 | #[macro_use] 241 | extern crate serde_derive; 242 | use serde_json; 243 | 244 | #[cfg(test)] 245 | extern crate serde_test; 246 | 247 | #[macro_use] 248 | mod macros; 249 | #[cfg(test)] 250 | #[macro_use] 251 | mod test; 252 | pub mod auth; 253 | mod routes; 254 | pub mod serde_custom; 255 | pub mod token; 256 | 257 | pub use self::routes::routes; 258 | 259 | use std::error; 260 | use std::fmt; 261 | use std::io; 262 | use std::ops::Deref; 263 | use std::str::FromStr; 264 | 265 | use ring::rand::SystemRandom; 266 | use rocket::http::Status; 267 | use rocket::response::{Responder, Response}; 268 | use rocket::Request; 269 | use serde::de; 270 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 271 | 272 | pub use serde_json::Map as JsonMap; 273 | pub use serde_json::Value as JsonValue; 274 | 275 | /// Top level error enum 276 | #[derive(Debug)] 277 | pub enum Error { 278 | /// A generic/unknown error 279 | GenericError(String), 280 | /// A bad request resulting from bad request parameters/headers 281 | BadRequest(String), 282 | /// Authentication error 283 | Auth(auth::Error), 284 | /// CORS error 285 | CORS(cors::Error), 286 | /// Token Error 287 | Token(token::Error), 288 | /// IO errors 289 | IOError(io::Error), 290 | /// An error launcing Rocket 291 | LaunchError(rocket::error::LaunchError), 292 | 293 | /// Unsupported operation 294 | UnsupportedOperation, 295 | } 296 | 297 | impl_from_error!(auth::Error, Error::Auth); 298 | impl_from_error!(cors::Error, Error::CORS); 299 | impl_from_error!(token::Error, Error::Token); 300 | impl_from_error!(String, Error::GenericError); 301 | impl_from_error!(io::Error, Error::IOError); 302 | impl_from_error!(rocket::error::LaunchError, Error::LaunchError); 303 | 304 | impl error::Error for Error { 305 | fn description(&self) -> &str { 306 | match *self { 307 | Error::UnsupportedOperation => "This operation is not supported", 308 | Error::Auth(ref e) => e.description(), 309 | Error::CORS(ref e) => e.description(), 310 | Error::Token(ref e) => e.description(), 311 | Error::IOError(ref e) => e.description(), 312 | Error::LaunchError(ref e) => e.description(), 313 | Error::GenericError(ref e) | Error::BadRequest(ref e) => e, 314 | } 315 | } 316 | 317 | fn cause(&self) -> Option<&dyn error::Error> { 318 | match *self { 319 | Error::Auth(ref e) => Some(e), 320 | Error::CORS(ref e) => Some(e), 321 | Error::Token(ref e) => Some(e), 322 | Error::IOError(ref e) => Some(e), 323 | Error::LaunchError(ref e) => Some(e), 324 | Error::UnsupportedOperation | Error::GenericError(_) | Error::BadRequest(_) => { 325 | Some(self) 326 | } 327 | } 328 | } 329 | } 330 | 331 | impl fmt::Display for Error { 332 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 333 | match *self { 334 | Error::UnsupportedOperation => write!(f, "{}", error::Error::description(self)), 335 | Error::Auth(ref e) => fmt::Display::fmt(e, f), 336 | Error::CORS(ref e) => fmt::Display::fmt(e, f), 337 | Error::Token(ref e) => fmt::Display::fmt(e, f), 338 | Error::IOError(ref e) => fmt::Display::fmt(e, f), 339 | Error::GenericError(ref e) => fmt::Display::fmt(e, f), 340 | Error::LaunchError(ref e) => fmt::Display::fmt(e, f), 341 | Error::BadRequest(ref e) => fmt::Display::fmt(e, f), 342 | } 343 | } 344 | } 345 | 346 | impl<'r> Responder<'r> for Error { 347 | fn respond_to(self, request: &Request<'_>) -> Result, Status> { 348 | match self { 349 | Error::Auth(e) => e.respond_to(request), 350 | Error::CORS(e) => e.respond_to(request), 351 | Error::Token(e) => e.respond_to(request), 352 | Error::BadRequest(e) => { 353 | error_!("{}", e); 354 | Err(Status::BadRequest) 355 | } 356 | e => { 357 | error_!("{}", e); 358 | Err(Status::InternalServerError) 359 | } 360 | } 361 | } 362 | } 363 | 364 | /// Wrapper around `hyper::Url` with `Serialize` and `Deserialize` implemented 365 | #[derive(Clone, Eq, PartialEq, Hash, Debug)] 366 | pub struct Url(hyper::Url); 367 | impl_deref!(Url, hyper::Url); 368 | 369 | impl FromStr for Url { 370 | type Err = hyper::error::ParseError; 371 | 372 | fn from_str(s: &str) -> Result { 373 | Ok(Url(hyper::Url::from_str(s)?)) 374 | } 375 | } 376 | 377 | impl fmt::Display for Url { 378 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 379 | write!(f, "{}", self.0.as_str()) 380 | } 381 | } 382 | 383 | impl Serialize for Url { 384 | fn serialize(&self, serializer: S) -> Result 385 | where 386 | S: Serializer, 387 | { 388 | serializer.serialize_str(self.0.as_str()) 389 | } 390 | } 391 | 392 | impl<'de> Deserialize<'de> for Url { 393 | fn deserialize(deserializer: D) -> Result 394 | where 395 | D: Deserializer<'de>, 396 | { 397 | struct UrlVisitor; 398 | impl<'de> de::Visitor<'de> for UrlVisitor { 399 | type Value = Url; 400 | 401 | fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 402 | formatter.write_str("a valid URL string") 403 | } 404 | 405 | fn visit_string(self, value: String) -> Result 406 | where 407 | E: de::Error, 408 | { 409 | Ok(Url( 410 | hyper::Url::from_str(&value).map_err(|e| E::custom(e.to_string()))? 411 | )) 412 | } 413 | 414 | fn visit_str(self, value: &str) -> Result 415 | where 416 | E: de::Error, 417 | { 418 | Ok(Url( 419 | hyper::Url::from_str(value).map_err(|e| E::custom(e.to_string()))? 420 | )) 421 | } 422 | } 423 | 424 | deserializer.deserialize_string(UrlVisitor) 425 | } 426 | } 427 | 428 | /// A sequence of bytes, either as an array of unsigned 8 bit integers, or a string which will be 429 | /// treated as UTF-8. 430 | /// This enum is (de)serialized [`untagged`](https://serde.rs/enum-representations.html). 431 | #[derive(Clone, Eq, PartialEq, Serialize, Deserialize, Debug)] 432 | #[serde(untagged)] 433 | pub enum ByteSequence { 434 | /// A string which will be converted to UTF-8 and then to bytes. 435 | String(String), 436 | /// A sequence of unsigned 8 bits integers which will be treated as bytes. 437 | Bytes(Vec), 438 | } 439 | 440 | impl ByteSequence { 441 | /// Returns the byte sequence. 442 | pub fn as_bytes(&self) -> Vec { 443 | match *self { 444 | ByteSequence::String(ref string) => string.to_string().into_bytes(), 445 | ByteSequence::Bytes(ref bytes) => bytes.to_vec(), 446 | } 447 | } 448 | } 449 | 450 | /// Application configuration. Usually deserialized from JSON for use. 451 | /// 452 | /// The type parameter `B` is the [`auth::AuthenticatorConfiguration`] and by its associated 453 | /// type, the `Authenticator` that is going to be used for HTTP Basic Authentication. 454 | /// 455 | /// # Examples 456 | /// ``` 457 | /// extern crate rowdy; 458 | /// extern crate serde_json; 459 | /// 460 | /// use rowdy::Configuration; 461 | /// use rowdy::auth::NoOpConfiguration; 462 | /// 463 | /// # fn main() { 464 | /// // We are using the `NoOp` authenticator 465 | /// let json = r#"{ 466 | /// "token" : { 467 | /// "issuer": "https://www.acme.com", 468 | /// "allowed_origins": { "Some": ["https://www.example.com", "https://www.foobar.com"] }, 469 | /// "audience": ["https://www.example.com", "https://www.foobar.com"], 470 | /// "signature_algorithm": "RS256", 471 | /// "secret": { 472 | /// "rsa_private": "test/fixtures/rsa_private_key.der", 473 | /// "rsa_public": "test/fixtures/rsa_public_key.der" 474 | /// }, 475 | /// "expiry_duration": 86400 476 | /// }, 477 | /// "basic_authenticator": {} 478 | /// }"#; 479 | /// let config: Configuration = serde_json::from_str(json).unwrap(); 480 | /// let rocket = config.ignite().unwrap().mount("/", rowdy::routes()); 481 | /// // then `rocket.launch()`! 482 | /// # } 483 | /// ``` 484 | #[derive(Serialize, Deserialize, Debug, Clone)] 485 | pub struct Configuration { 486 | /// Token configuration. See the type documentation for deserialization examples 487 | pub token: token::Configuration, 488 | /// The configuration for the authenticator that will handle HTTP Basic Authentication. 489 | pub basic_authenticator: B, 490 | } 491 | 492 | impl> Configuration { 493 | /// Ignites the rocket with various configuration objects, but does not mount any routes. 494 | /// Remember to mount routes and call `launch` on the returned Rocket object. 495 | /// See the struct documentation for an example. 496 | pub fn ignite(&self) -> Result { 497 | let token_getter_cors_options = self.token.cors_option(); 498 | 499 | let basic_authenticator = self.basic_authenticator.make_authenticator()?; 500 | let basic_authenticator: Box = Box::new(basic_authenticator); 501 | 502 | // Prepare the keys 503 | let keys = self.token.keys()?; 504 | 505 | Ok(rocket::ignite() 506 | .manage(self.token.clone()) 507 | .manage(basic_authenticator) 508 | .manage(keys) 509 | .attach(token_getter_cors_options)) 510 | } 511 | } 512 | 513 | /// Convenience function to ignite and launch rowdy. This function will never return 514 | /// 515 | /// # Panics 516 | /// Panics if during the Rocket igition, something goes wrong. 517 | /// 518 | /// # Example 519 | /// ```rust,no_run 520 | /// extern crate rowdy; 521 | /// extern crate serde_json; 522 | /// 523 | /// use rowdy::Configuration; 524 | /// use rowdy::auth::NoOpConfiguration; 525 | /// 526 | /// # fn main() { 527 | /// // We are using the `NoOp` authenticator 528 | /// let json = r#"{ 529 | /// "token" : { 530 | /// "issuer": "https://www.acme.com", 531 | /// "allowed_origins": ["https://www.example.com", "https://www.foobar.com"], 532 | /// "audience": ["https://www.example.com", "https://www.foobar.com"], 533 | /// "signature_algorithm": "RS256", 534 | /// "secret": { 535 | /// "rsa_private": "test/fixtures/rsa_private_key.der", 536 | /// "rsa_public": "test/fixtures/rsa_public_key.der" 537 | /// }, 538 | /// "expiry_duration": 86400 539 | /// }, 540 | /// "basic_authenticator": {} 541 | /// }"#; 542 | /// let config: Configuration = serde_json::from_str(json).unwrap(); 543 | /// 544 | /// rowdy::launch(config); 545 | /// # } 546 | /// ``` 547 | pub fn launch>( 548 | config: Configuration, 549 | ) -> rocket::error::LaunchError { 550 | let rocket = config.ignite().unwrap_or_else(|e| panic!("{}", e)); 551 | rocket.mount("/", routes()).launch() 552 | } 553 | 554 | /// Return a psuedo random number generator 555 | pub(crate) fn rng() -> &'static SystemRandom { 556 | use std::ops::Deref; 557 | 558 | lazy_static! { 559 | static ref RANDOM: SystemRandom = SystemRandom::new(); 560 | } 561 | 562 | RANDOM.deref() 563 | } 564 | 565 | #[cfg(test)] 566 | mod tests { 567 | use std::str::FromStr; 568 | 569 | use serde_test::{assert_tokens, Token}; 570 | 571 | use super::*; 572 | 573 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] 574 | struct TestUrl { 575 | url: Url, 576 | } 577 | 578 | #[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] 579 | struct TestClaims { 580 | company: String, 581 | department: String, 582 | } 583 | 584 | impl Default for TestClaims { 585 | fn default() -> Self { 586 | TestClaims { 587 | company: "ACME".to_string(), 588 | department: "Toilet Cleaning".to_string(), 589 | } 590 | } 591 | } 592 | 593 | #[test] 594 | fn url_serialization_token_round_trip() { 595 | let test = TestUrl { 596 | url: not_err!(Url::from_str("https://www.example.com/")), 597 | }; 598 | 599 | assert_tokens( 600 | &test, 601 | &[ 602 | Token::Struct { 603 | name: "TestUrl", 604 | len: 1, 605 | }, 606 | Token::Str("url"), 607 | Token::Str("https://www.example.com/"), 608 | Token::StructEnd, 609 | ], 610 | ); 611 | } 612 | } 613 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | /// Implement a straightforward conversion of error type 2 | macro_rules! impl_from_error { 3 | ($f: ty, $e: expr) => { 4 | impl From<$f> for Error { 5 | fn from(f: $f) -> Error { 6 | $e(f) 7 | } 8 | } 9 | }; 10 | } 11 | 12 | /// Implement a simple Deref from `From` to `To` where `From` is a newtype struct containing `To` 13 | macro_rules! impl_deref { 14 | ($f:ty, $t:ty) => { 15 | impl Deref for $f { 16 | type Target = $t; 17 | 18 | fn deref(&self) -> &Self::Target { 19 | &self.0 20 | } 21 | } 22 | }; 23 | } 24 | 25 | /// Extract some value from an expression via pattern matching. T 26 | /// his is the cousin to `assert_matches!`. 27 | macro_rules! match_extract { 28 | ($e: expr, $p: pat, $f: expr) => { 29 | match $e { 30 | $p => Ok($f), 31 | _ => Err(format!( 32 | "{}: Expected pattern {} \ndid not match", 33 | stringify!($e), 34 | stringify!($p) 35 | ) 36 | .to_string()), 37 | } 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/routes.rs: -------------------------------------------------------------------------------- 1 | //! Routes mounted into Rocket 2 | 3 | // mounted via `::launch()` 4 | #![allow(unmounted_route)] 5 | 6 | use hyper; 7 | use rocket::request::Form; 8 | use rocket::{Route, State}; 9 | 10 | use crate::auth; 11 | use crate::token::{Configuration, Keys, PrivateClaim, RefreshToken, Token}; 12 | 13 | #[derive(FromForm, Default, Clone, Debug)] 14 | struct AuthParam { 15 | service: String, 16 | scope: String, 17 | offline_token: Option, 18 | } 19 | 20 | impl AuthParam { 21 | /// Verify the params are correct for the authentication type, that is if the authorization 22 | /// is a bearer token, then an offline token cannot be requested. 23 | fn verify( 24 | &self, 25 | authorization: &auth::Authorization, 26 | ) -> Result<(), crate::Error> { 27 | if authorization.is_bearer() && self.offline_token.is_some() { 28 | Err(crate::Error::BadRequest( 29 | "Offline token cannot be requested for when authenticating with a refresh token" 30 | .to_string(), 31 | ))? 32 | } 33 | Ok(()) 34 | } 35 | } 36 | 37 | /// Access token retrieval via initial authentication route 38 | #[get("/?", rank = 1)] 39 | fn token_getter( 40 | authorization: auth::Authorization, 41 | auth_param: Form, 42 | configuration: State<'_, Configuration>, 43 | keys: State<'_, Keys>, 44 | authenticator: State<'_, Box>, 45 | ) -> Result, crate::Error> { 46 | auth_param.verify(&authorization)?; 47 | authenticator 48 | .prepare_authentication_response(&authorization, auth_param.offline_token.unwrap_or(false)) 49 | .and_then(|result| { 50 | let token = Token::::with_configuration( 51 | &configuration, 52 | &result.subject, 53 | &auth_param.service, 54 | result.private_claims.clone(), 55 | result.refresh_payload.as_ref(), 56 | )?; 57 | let signing_key = &keys.signing; 58 | let token = token.encode(signing_key)?; 59 | 60 | let token = if configuration.refresh_token_enabled() && token.has_refresh_token() { 61 | let refresh_token_key = keys 62 | .encryption 63 | .as_ref() 64 | .expect("Refresh token was enabled but encryption key is missing"); 65 | token.encrypt_refresh_token(signing_key, refresh_token_key)? 66 | } else { 67 | token 68 | }; 69 | 70 | Ok(token) 71 | }) 72 | } 73 | 74 | /// Access token retrieval via refresh token route 75 | #[get("/?", rank = 2)] 76 | fn refresh_token( 77 | authorization: auth::Authorization, 78 | auth_param: Form, 79 | configuration: State<'_, Configuration>, 80 | keys: State<'_, Keys>, 81 | authenticator: State<'_, Box>, 82 | ) -> Result, crate::Error> { 83 | if !configuration.refresh_token_enabled() { 84 | return Err(crate::Error::BadRequest( 85 | "Refresh token is not enabled".to_string(), 86 | )); 87 | } 88 | let refresh_token_configuration = configuration.refresh_token(); 89 | 90 | auth_param.verify(&authorization)?; 91 | let refresh_token = RefreshToken::new_encrypted(&authorization.token()); 92 | let refresh_token = refresh_token.decrypt( 93 | &keys.signature_verification, 94 | keys.decryption 95 | .as_ref() 96 | .expect("Refresh token was enabled but decryption key is missing"), 97 | configuration.signature_algorithm.unwrap_or_default(), 98 | refresh_token_configuration.cek_algorithm, 99 | refresh_token_configuration.enc_algorithm, 100 | )?; 101 | 102 | refresh_token.validate(&auth_param.service, &configuration, None)?; 103 | 104 | authenticator 105 | .prepare_refresh_response(refresh_token.payload()?) 106 | .and_then(|result| { 107 | let token = Token::::with_configuration( 108 | &configuration, 109 | &result.subject, 110 | &auth_param.service, 111 | result.private_claims.clone(), 112 | None, 113 | )?; 114 | let token = token.encode(&keys.signing)?; 115 | Ok(token) 116 | }) 117 | } 118 | 119 | /// Route to catch missing Authorization 120 | #[get("/?", rank = 3)] 121 | fn bad_request( 122 | auth_param: Form, 123 | configuration: State<'_, Configuration>, 124 | ) -> Result<(), crate::Error> { 125 | let _ = auth_param; 126 | auth::missing_authorization(&configuration.issuer.to_string()) 127 | } 128 | 129 | /// A simple "Ping Pong" route to check the health of the server 130 | #[get("/ping")] 131 | fn ping() -> &'static str { 132 | "Pong" 133 | } 134 | 135 | /// Return routes provided by rowdy 136 | pub fn routes() -> Vec { 137 | routes![token_getter, refresh_token, bad_request, ping,] 138 | } 139 | 140 | #[cfg(test)] 141 | mod tests { 142 | use std::str::FromStr; 143 | use std::time::Duration; 144 | 145 | use crate::jwt; 146 | use hyper; 147 | use rocket::http::{Header, Status}; 148 | use rocket::local::Client; 149 | use rocket::Rocket; 150 | use serde_json; 151 | 152 | use super::*; 153 | use crate::token::{RefreshTokenConfiguration, Secret}; 154 | use crate::ByteSequence; 155 | 156 | fn ignite() -> Rocket { 157 | // Ignite rocket 158 | let allowed_origins = ["https://www.example.com"]; 159 | let (allowed_origins, _) = crate::cors::AllowedOrigins::some(&allowed_origins); 160 | let token_configuration = Configuration { 161 | issuer: FromStr::from_str("https://www.acme.com").unwrap(), 162 | allowed_origins: allowed_origins, 163 | audience: jwt::SingleOrMultiple::Single(not_err!(FromStr::from_str( 164 | "https://www.example.com" 165 | ))), 166 | signature_algorithm: Some(jwt::jwa::SignatureAlgorithm::HS512), 167 | secret: Secret::ByteSequence(ByteSequence::String("secret".to_string())), 168 | expiry_duration: Duration::from_secs(120), 169 | refresh_token: Some(RefreshTokenConfiguration { 170 | cek_algorithm: jwt::jwa::KeyManagementAlgorithm::A256GCMKW, 171 | enc_algorithm: jwt::jwa::ContentEncryptionAlgorithm::A256GCM, 172 | key: Secret::ByteSequence(ByteSequence::Bytes(vec![0; 256 / 8])), 173 | expiry_duration: Duration::from_secs(86400), 174 | }), 175 | }; 176 | let configuration = crate::Configuration { 177 | token: token_configuration, 178 | basic_authenticator: crate::auth::tests::MockAuthenticatorConfiguration {}, 179 | }; 180 | 181 | let rocket = not_err!(configuration.ignite()); 182 | rocket.mount("/", routes()) 183 | } 184 | 185 | #[test] 186 | fn ping_pong() { 187 | let rocket = ignite(); 188 | let client = not_err!(Client::new(rocket)); 189 | 190 | let req = client.get("/ping"); 191 | let mut response = req.dispatch(); 192 | let body_str = not_none!(response.body().and_then(|body| body.into_string())); 193 | 194 | assert_eq!("Pong", body_str); 195 | } 196 | 197 | #[test] 198 | fn token_getter_options_test() { 199 | let rocket = ignite(); 200 | let client = not_err!(Client::new(rocket)); 201 | 202 | // Make headers 203 | let origin_header = Header::from(not_err!(hyper::header::Origin::from_str( 204 | "https://www.example.com" 205 | ))); 206 | let method_header = Header::from(hyper::header::AccessControlRequestMethod( 207 | hyper::method::Method::Get, 208 | )); 209 | let request_headers = 210 | hyper::header::AccessControlRequestHeaders(vec![ 211 | FromStr::from_str("Authorization").unwrap() 212 | ]); 213 | let request_headers = Header::from(request_headers); 214 | 215 | // Make and dispatch request 216 | let req = client 217 | .options("/?service=https://www.example.com&scope=all") 218 | .header(origin_header) 219 | .header(method_header) 220 | .header(request_headers); 221 | let response = req.dispatch(); 222 | 223 | // Assert 224 | assert!(response.status().class().is_success()); 225 | let origin_header = response 226 | .headers() 227 | .get_one("Access-Control-Allow-Origin") 228 | .expect("to exist"); 229 | assert_eq!("https://www.example.com", origin_header); 230 | } 231 | 232 | #[test] 233 | #[allow(deprecated)] 234 | fn token_getter_get_test() { 235 | let rocket = ignite(); 236 | let client = not_err!(Client::new(rocket)); 237 | 238 | // Make headers 239 | let origin_header = Header::from(not_err!(hyper::header::Origin::from_str( 240 | "https://www.example.com" 241 | ))); 242 | let auth_header = hyper::header::Authorization(auth::Basic { 243 | username: "mei".to_owned(), 244 | password: Some("冻住,不许走!".to_string()), 245 | }); 246 | let auth_header = Header::new( 247 | "Authorization", 248 | hyper::header::HeaderFormatter(&auth_header).to_string(), 249 | ); 250 | // Make and dispatch request 251 | let req = client 252 | .get("/?service=https://www.example.com&scope=all") 253 | .header(origin_header) 254 | .header(auth_header); 255 | let mut response = req.dispatch(); 256 | 257 | // Assert 258 | assert!(response.status().class().is_success()); 259 | let body_str = not_none!(response.body().and_then(|body| body.into_string())); 260 | let origin_header = response 261 | .headers() 262 | .get_one("Access-Control-Allow-Origin") 263 | .expect("to exist"); 264 | assert_eq!("https://www.example.com", origin_header); 265 | 266 | let deserialized: Token = not_err!(serde_json::from_str(&body_str)); 267 | let actual_token = not_err!(deserialized.decode( 268 | &jwt::jws::Secret::bytes_from_str("secret"), 269 | jwt::jwa::SignatureAlgorithm::HS512, 270 | )); 271 | 272 | assert!(actual_token.refresh_token.is_none()); 273 | 274 | let registered = not_err!(actual_token.registered_claims()); 275 | assert_eq!( 276 | Some(FromStr::from_str("https://www.acme.com").unwrap()), 277 | registered.issuer 278 | ); 279 | assert_eq!( 280 | Some(jwt::SingleOrMultiple::Single( 281 | FromStr::from_str("https://www.example.com").unwrap(), 282 | ),), 283 | registered.audience 284 | ); 285 | 286 | // TODO: Test private claims 287 | 288 | let header = not_err!(actual_token.header()); 289 | assert_eq!( 290 | header.registered.algorithm, 291 | jwt::jwa::SignatureAlgorithm::HS512 292 | ); 293 | } 294 | 295 | #[test] 296 | #[allow(deprecated)] 297 | fn token_getter_get_invalid_credentials() { 298 | // Ignite rocket 299 | let rocket = ignite(); 300 | let client = not_err!(Client::new(rocket)); 301 | 302 | // Make headers 303 | let origin_header = Header::from(not_err!(hyper::header::Origin::from_str( 304 | "https://www.example.com" 305 | ))); 306 | let auth_header = hyper::header::Authorization(auth::Basic { 307 | username: "Aladin".to_owned(), 308 | password: Some("let me in".to_string()), 309 | }); 310 | let auth_header = Header::new( 311 | "Authorization", 312 | hyper::header::HeaderFormatter(&auth_header).to_string(), 313 | ); 314 | // Make and dispatch request 315 | let req = client 316 | .get("/?service=https://www.example.com&scope=all") 317 | .header(origin_header) 318 | .header(auth_header); 319 | let response = req.dispatch(); 320 | 321 | // Assert 322 | assert_eq!(response.status(), Status::Unauthorized); 323 | let origin_header = response 324 | .headers() 325 | .get_one("Access-Control-Allow-Origin") 326 | .expect("to exist"); 327 | assert_eq!("https://www.example.com", origin_header); 328 | } 329 | 330 | #[test] 331 | #[allow(deprecated)] 332 | fn token_getter_get_missing_credentials() { 333 | // Ignite rocket 334 | let rocket = ignite(); 335 | let client = not_err!(Client::new(rocket)); 336 | 337 | // Make headers 338 | let origin_header = Header::from(not_err!(hyper::header::Origin::from_str( 339 | "https://www.example.com" 340 | ))); 341 | 342 | // Make and dispatch request 343 | let req = client 344 | .get("/?service=https://www.example.com&scope=all") 345 | .header(origin_header); 346 | let response = req.dispatch(); 347 | 348 | // Assert 349 | assert_eq!(response.status(), Status::Unauthorized); 350 | let origin_header = response 351 | .headers() 352 | .get_one("Access-Control-Allow-Origin") 353 | .expect("to exist"); 354 | assert_eq!("https://www.example.com", origin_header); 355 | 356 | let www_header: Vec<_> = response.headers().get("WWW-Authenticate").collect(); 357 | assert_eq!(www_header, vec!["Basic realm=https://www.acme.com/"]); 358 | } 359 | 360 | #[test] 361 | #[allow(deprecated)] 362 | fn token_getter_get_invalid_service() { 363 | // Ignite rocket 364 | let rocket = ignite(); 365 | let client = not_err!(Client::new(rocket)); 366 | 367 | // Make headers 368 | let origin_header = Header::from(not_err!(hyper::header::Origin::from_str( 369 | "https://www.example.com" 370 | ))); 371 | let auth_header = hyper::header::Authorization(auth::Basic { 372 | username: "mei".to_owned(), 373 | password: Some("冻住,不许走!".to_string()), 374 | }); 375 | let auth_header = Header::new( 376 | "Authorization", 377 | hyper::header::HeaderFormatter(&auth_header).to_string(), 378 | ); 379 | // Make and dispatch request 380 | let req = client 381 | .get("/?service=foobar&scope=all") 382 | .header(origin_header) 383 | .header(auth_header); 384 | let response = req.dispatch(); 385 | 386 | // Assert 387 | assert_eq!(response.status(), Status::Forbidden); 388 | let origin_header = response 389 | .headers() 390 | .get_one("Access-Control-Allow-Origin") 391 | .expect("to exist"); 392 | assert_eq!("https://www.example.com", origin_header); 393 | } 394 | 395 | /// Tests that we can request a refresh token and then get a new access token with the 396 | /// issued refresh token 397 | #[test] 398 | #[allow(deprecated)] 399 | fn token_getter_with_refresh_token_round_trip() { 400 | let rocket = ignite(); 401 | let client = not_err!(Client::new(rocket)); 402 | 403 | // Initial authentication request 404 | // Make headers 405 | let origin_header = Header::from(not_err!(hyper::header::Origin::from_str( 406 | "https://www.example.com" 407 | ))); 408 | let auth_header = hyper::header::Authorization(auth::Basic { 409 | username: "mei".to_owned(), 410 | password: Some("冻住,不许走!".to_string()), 411 | }); 412 | let auth_header = Header::new( 413 | "Authorization", 414 | hyper::header::HeaderFormatter(&auth_header).to_string(), 415 | ); 416 | // Make and dispatch request 417 | let req = client 418 | .get("/?service=https://www.example.com&scope=all&offline_token=true") 419 | .header(origin_header) 420 | .header(auth_header); 421 | let mut response = req.dispatch(); 422 | 423 | // Assert 424 | assert!(response.status().class().is_success()); 425 | let body_str = not_none!(response.body().and_then(|body| body.into_string())); 426 | let origin_header = response 427 | .headers() 428 | .get_one("Access-Control-Allow-Origin") 429 | .expect("to exist"); 430 | assert_eq!("https://www.example.com", origin_header); 431 | 432 | let deserialized: Token = not_err!(serde_json::from_str(&body_str)); 433 | let actual_token = not_err!(deserialized.decode( 434 | &jwt::jws::Secret::bytes_from_str("secret"), 435 | jwt::jwa::SignatureAlgorithm::HS512, 436 | )); 437 | 438 | let refresh_token = actual_token.refresh_token.unwrap(); 439 | 440 | // Use refresh token to authenticate 441 | let origin_header = Header::from(not_err!(hyper::header::Origin::from_str( 442 | "https://www.example.com" 443 | ))); 444 | let auth_header = hyper::header::Authorization(auth::Bearer { 445 | token: refresh_token.to_string().unwrap(), 446 | }); 447 | let auth_header = Header::new( 448 | "Authorization", 449 | hyper::header::HeaderFormatter(&auth_header).to_string(), 450 | ); 451 | // Make and dispatch request 452 | let req = client 453 | .get("/?service=https://www.example.com&scope=all") 454 | .header(origin_header) 455 | .header(auth_header); 456 | let mut response = req.dispatch(); 457 | 458 | // Assert 459 | assert!(response.status().class().is_success()); 460 | let body_str = not_none!(response.body().and_then(|body| body.into_string())); 461 | let origin_header = response 462 | .headers() 463 | .get_one("Access-Control-Allow-Origin") 464 | .expect("to exist"); 465 | assert_eq!("https://www.example.com", origin_header); 466 | 467 | let deserialized: Token = not_err!(serde_json::from_str(&body_str)); 468 | let actual_token = not_err!(deserialized.decode( 469 | &jwt::jws::Secret::bytes_from_str("secret"), 470 | jwt::jwa::SignatureAlgorithm::HS512, 471 | )); 472 | assert!(actual_token.refresh_token.is_none()); 473 | 474 | let registered = not_err!(actual_token.registered_claims()); 475 | assert_eq!( 476 | Some(FromStr::from_str("https://www.acme.com").unwrap()), 477 | registered.issuer 478 | ); 479 | assert_eq!( 480 | Some(jwt::SingleOrMultiple::Single( 481 | FromStr::from_str("https://www.example.com").unwrap(), 482 | ),), 483 | registered.audience 484 | ); 485 | 486 | // TODO: Test private claims 487 | 488 | let header = not_err!(actual_token.header()); 489 | assert_eq!( 490 | header.registered.algorithm, 491 | jwt::jwa::SignatureAlgorithm::HS512 492 | ); 493 | } 494 | 495 | /// Requesting for a refresh token when using a refresh token to authenticate should 496 | /// result in Bad Request 497 | #[test] 498 | #[allow(deprecated)] 499 | fn token_refresh_with_offline_token_should_return_bad_request() { 500 | let rocket = ignite(); 501 | let client = not_err!(Client::new(rocket)); 502 | 503 | let origin_header = Header::from(not_err!(hyper::header::Origin::from_str( 504 | "https://www.example.com" 505 | ))); 506 | let auth_header = hyper::header::Authorization(auth::Bearer { 507 | token: "foobar".to_string(), 508 | }); 509 | let auth_header = Header::new( 510 | "Authorization", 511 | hyper::header::HeaderFormatter(&auth_header).to_string(), 512 | ); 513 | // Make and dispatch request 514 | let req = client 515 | .get("/?service=https://www.example.com&scope=all&offline_token=true") 516 | .header(origin_header) 517 | .header(auth_header); 518 | let response = req.dispatch(); 519 | 520 | // Assert 521 | assert_eq!(response.status(), Status::BadRequest); 522 | let origin_header = response 523 | .headers() 524 | .get_one("Access-Control-Allow-Origin") 525 | .expect("to exist"); 526 | assert_eq!("https://www.example.com", origin_header); 527 | } 528 | } 529 | -------------------------------------------------------------------------------- /src/serde_custom/duration.rs: -------------------------------------------------------------------------------- 1 | //! Custom serializer and deserializer for `std::time::Duration`. Serializes to seconds, 2 | //! and deserializes from seconds. 3 | use serde::{Deserialize, Deserializer, Serializer}; 4 | use std::time::Duration; 5 | 6 | /// Serialize a `Duration` into a `u64` representing the seconds 7 | pub fn serialize(duration: &Duration, serializer: S) -> Result 8 | where 9 | S: Serializer, 10 | { 11 | serializer.serialize_u64(duration.as_secs()) 12 | } 13 | 14 | /// From a `u64`, deserialize into a `Duration` with the `u64` in seconds 15 | pub fn deserialize<'de, D>(deserializer: D) -> Result 16 | where 17 | D: Deserializer<'de>, 18 | { 19 | let duration = u64::deserialize(deserializer)?; 20 | Ok(Duration::from_secs(duration)) 21 | } 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use serde_json; 26 | use std::time::Duration; 27 | 28 | #[derive(Serialize, Deserialize, Eq, PartialEq, Debug)] 29 | struct TestStruct { 30 | #[serde(with = "super")] 31 | duration: Duration, 32 | } 33 | 34 | #[test] 35 | fn serialization_round_trip() { 36 | let structure = TestStruct { 37 | duration: Duration::from_secs(1234), 38 | }; 39 | 40 | let expected_json = "{\"duration\":1234}"; 41 | let actual_json = not_err!(serde_json::to_string(&structure)); 42 | assert_eq!(expected_json, actual_json); 43 | 44 | let deserialized_struct: TestStruct = not_err!(serde_json::from_str(&actual_json)); 45 | assert_eq!(structure, deserialized_struct); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/serde_custom/mod.rs: -------------------------------------------------------------------------------- 1 | //! Custom serde serialization and deserialization. 2 | pub mod duration; 3 | -------------------------------------------------------------------------------- /src/test.rs: -------------------------------------------------------------------------------- 1 | macro_rules! not_err { 2 | ($e:expr) => { 3 | match $e { 4 | Ok(e) => e, 5 | Err(e) => panic!("{} failed with {:?}", stringify!($e), e), 6 | } 7 | }; 8 | } 9 | 10 | macro_rules! not_none { 11 | ($e:expr) => { 12 | match $e { 13 | Some(e) => e, 14 | None => panic!("{} failed with None", stringify!($e)), 15 | } 16 | }; 17 | } 18 | 19 | macro_rules! assert_matches_non_debug { 20 | ($e: expr, $p: pat) => { 21 | assert_matches_non_debug!($e, $p, ()) 22 | }; 23 | ($e: expr, $p: pat, $f: expr) => { 24 | match $e { 25 | $p => $f, 26 | _ => panic!( 27 | "{}: Expected pattern {} \ndoes not match {}", 28 | stringify!($e), 29 | stringify!($p), 30 | stringify!($e) 31 | ), 32 | } 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /test/fixtures/rsa_private_key.der: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawliet89/rowdy/44cf405ad9330ae073889bd043c4e0f02c57e290/test/fixtures/rsa_private_key.der -------------------------------------------------------------------------------- /test/fixtures/rsa_private_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA1XM2Qy7bkaCpjjvClZwI15AXy9966m7c3sYR2nRuHb0UT7Q5 3 | EWPnl/s5LEOMxwrqiXltj8/2lkZlWtAuABabXxxMkVDTOZ2A3ObY9vBXsQX1/F7n 4 | dLCo/yCmfYDmcH04BGLSzcuRPm7p6nWFzVBK5FtpMLxxPQKZnja/RJz/ojhTdPOF 5 | CBkFYgeICi6LpH7oqGy+TDYdbVS5Xy4WaCYvecJ3TUI0uNXG0VoOlUk+MIqpjwAq 6 | eLuSjLePHn4GJDq21+r6tZ9kRndLBHn2WT+I92OXjxTvt/QitMZujrU/9ebcTTyS 7 | lS2EE+BuLZ6x31DYIk/zvTGf9eQljQbFeLlSewIDAQABAoIBACr9BC2trzz6HYvu 8 | zzawcTtw4soFnUy/vS4EuC3GCzNkFEYlJuUwuMDsMMyQYjboJOpBEWbIXIJRdTJA 9 | ATO1Wd9i5KzTmWbeKMjUmVfKee7GI4+LUZQ3zCFt4vodzstS/MgtWwVlfUAUuHmm 10 | 56a9CAhLvLi7CxddgbDSl9zqvbVewk5r/QHlHvErLeX1IkoK+qpFpCAq0hjGv1mB 11 | SfuGa6nNubgX9xCskqDXwPp2mCvuMVFhtt4F9fcGgMAuS3NrHc6aPV3Aej2URdax 12 | SM30xUCaCEtYWe+VQqwNGZiA0r4IXQP6byn3IYwnm3JAGkxLg7pBqEdBxZsZCvlr 13 | gVQLPNkCgYEA+5fOSRIT7OQ2PeDXb0frvfkJ7goLDEmrXa0jH2xXD0ZA5Yuf/I7I 14 | 53lphSnYDaMXEhUFpxc/JnxFVCDkEpCWDC/nRoo6zcGBPRF4YiSfYPujeJisf8im 15 | TaGDIruHNYws1M9pir0dYkbMr++MkYCWGb7U1KYVjtL4hYbQj9AHUH0CgYEA2TBd 16 | jNeWBXV+mxOT+ALdq5AOdIWba1/jJ3B5Vaf+Y4SwEcUDvIaI3IXrL+faQvzGLCuB 17 | DehsV/OTpy8xziYvFlhWVC6JuvZWNXVXRxC7oqvp161VOveZ+UfKQZx6BvGqw57O 18 | pP9LUR7OboK9QsoHTASwKFL7d0ZwshKJtjN6WFcCgYEA5gx/9jaOe5yMsHXn53v2 19 | 5gVSfBM42Op/xC8tH218CZ5udrX9+vxAXc+ZmcaSJJ4M2V7ZhVhvSOx2pB9TDFqi 20 | qNAghFKexEb8um9ACVV9Wjud1QadLFa3IeLeOqMIVgEveQOSeObFeHhOb0z11pGi 21 | LjZc+3hF3AuybL3B9M398i0CgYA9WTwTnJHz2Mx6YX1agPS8kWSD5XmRSvSPH2Ym 22 | m91vnvtdCZmUlyvxnqJgVc2BPoV71I4Pr6dq8JK0ltAquv5yAWHhRYQCG7MeRpbw 23 | q/lUadsT4RJCJc6Ia47mGZ0eeQUTXLhiQvqzX1BQRv3O7+I/xtM7kLUXa/5JTpM3 24 | tDLK4QKBgQC0q8at/rM1erbV97ujxqRzxLBFklM/Pv51lKy2ebewkHkjRUeimRXI 25 | q6yfNkHBFW/oPnR1h0rI7AsvjykVMDt4NmVQZZroPhzSN5DKgZmPGaDxgZkRiPEy 26 | +6tbSTlfl8hk3aljveIwwzTwveR26+Gy/DayW4rNN2jEUDVvc1bv5A== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/fixtures/rsa_public_key.der: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lawliet89/rowdy/44cf405ad9330ae073889bd043c4e0f02c57e290/test/fixtures/rsa_public_key.der -------------------------------------------------------------------------------- /test/fixtures/rsa_public_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PUBLIC KEY----- 2 | MIIBCgKCAQEA1XM2Qy7bkaCpjjvClZwI15AXy9966m7c3sYR2nRuHb0UT7Q5EWPn 3 | l/s5LEOMxwrqiXltj8/2lkZlWtAuABabXxxMkVDTOZ2A3ObY9vBXsQX1/F7ndLCo 4 | /yCmfYDmcH04BGLSzcuRPm7p6nWFzVBK5FtpMLxxPQKZnja/RJz/ojhTdPOFCBkF 5 | YgeICi6LpH7oqGy+TDYdbVS5Xy4WaCYvecJ3TUI0uNXG0VoOlUk+MIqpjwAqeLuS 6 | jLePHn4GJDq21+r6tZ9kRndLBHn2WT+I92OXjxTvt/QitMZujrU/9ebcTTySlS2E 7 | E+BuLZ6x31DYIk/zvTGf9eQljQbFeLlSewIDAQAB 8 | -----END RSA PUBLIC KEY----- 9 | -------------------------------------------------------------------------------- /test/fixtures/users.csv: -------------------------------------------------------------------------------- 1 | mei,aac846b3ef07dc88f417cc73775e32724580c17b2068c11b722e9dc6a220c0e8,37a82d20d2f53963b1ac7934e9fc9b80c5778bc51bd57ccb33543d2da0d25069 2 | foobar,615585bfbdd7c762174fff0b026881900c29828f504df7f87b213872b057b8dc,25c9fee3f2cf30e278aaf8b2b42f18a73dd39b77cfd08bedbe93d9ba3c90befa 3 | --------------------------------------------------------------------------------