Skip to content

Commit

Permalink
Add JWT Auth (#2)
Browse files Browse the repository at this point in the history
RD-47

* Add biscuit crate for JWT 🍪

* Remove inline attribute

* Replace clone with reference whenever possible

* Create abstraction for general auth validation

* Verify if auth header exists

* Implement JWT validation 🎉

* Change default algorithm to RS256

* Add default jwt auth example

* Add jwt auth example with expiration

* Refactor validate function in auth::jwt module

* Fix CI to only run test on lib, not examples

* Fix lint error by excluding examples

* Add .editorconfig

* Rename enum Auth to AuthMode

* Make AuthMode::JWT always use RS256

* Move dummy token to test/fixture folder
  • Loading branch information
DrSensor authored Nov 13, 2019
1 parent 6c5eda4 commit 84be5ef
Show file tree
Hide file tree
Showing 16 changed files with 356 additions and 74 deletions.
22 changes: 22 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# http://editorconfig.org
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.key]
insert_final_newline = false

[*.md]
max_line_length = off

[*.{rs,sh}]
indent_style = space
indent_size = 4

[{*Dockerfile,*.{yml,yaml,toml}}]
indent_style = space
indent_size = 2
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ jobs:
- name: Compile Tests
run: cargo build --verbose
- name: Unit Tests
run: cargo test
run: cargo test --lib
- name: Linter
run: cargo clippy --all-targets --all-features -- -D warnings --verbose
run: cargo clippy --all-features -- -D warnings --verbose
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Project specifics
*.pem
*.der

# Compiled files and executables
/target

Expand Down
4 changes: 2 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ jobs:
fast_finish: true
script:
- cargo build --verbose
- cargo test
- cargo test --lib
- rustup component add clippy
- cargo clippy --all-targets --all-features -- -D warnings --verbose
- cargo clippy --all-features -- -D warnings --verbose
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ overflow-checks = false
# Package dependencies

[dependencies]
biscuit = "*"
openssl = { version = "*", features = ["vendored"] }
serde = { version = "*", features = ["derive"] }
serde_json = "*"
Expand All @@ -73,3 +74,6 @@ futures-locks = "*"
crossbeam-channel = "*"
crossbeam-utils = "*"
mimalloc = { version = "*", default-features = false }

[dev-dependencies]
once_cell = "*"
31 changes: 31 additions & 0 deletions examples/jwt_periodic_broadcast.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//! How to run this and also generate public_key.der
//! 1. visit https://jwt.io and select `Algorithm: RS256`
//! 2. copy the public key into public_key.pm
//! 3. `openssl rsa -pubin -in public_key.pem -outform DER -out public_key.der -RSAPublicKey_out`
//! 4. `cargo run --example jwt_periodic_broadcast`
//! 5. copy the token from jwt.io **Encoded** text field
//! 6. `websocat ws://127.0.0.1:8080/ws/love --header="Authorization: Bearer ${TOKEN}"`
#[global_allocator]
static GLOBAL: bitwyre_ws_core::mimalloc::MiMalloc = bitwyre_ws_core::mimalloc::MiMalloc;

use bitwyre_ws_core::{init_log, run_periodic_websocket_service};
use bitwyre_ws_core::{AuthMode, PeriodicWebsocketConfig, PeriodicWebsocketState};
use once_cell::sync::Lazy;
use std::{io, sync::Arc, time::Duration};

fn main() -> io::Result<()> {
init_log(true, None);
static STATE: Lazy<PeriodicWebsocketState> = Lazy::new(|| {
PeriodicWebsocketState::new(PeriodicWebsocketConfig {
binding_url: "0.0.0.0:8080".into(),
binding_path: "/ws/love".into(),
max_clients: 16384,
periodic_interval: Duration::from_millis(1000),
rapid_request_limit: Duration::from_millis(1000),
periodic_message_getter: Arc::new(&|| "love".into()),
auth: AuthMode::default_jwt_from(include_bytes!("../public_key.der")),
})
});
run_periodic_websocket_service(Arc::new(&STATE))
}
51 changes: 51 additions & 0 deletions examples/jwt_periodic_broadcast_with_expiration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//! How to run this and also generate public_key.der
//! 1. visit https://jwt.io and select `Algorithm: RS256`
//! 2. copy the public key into public_key.pm
//! 3. `openssl rsa -pubin -in public_key.pem -outform DER -out public_key.der -RSAPublicKey_out`
//! 4. `cargo run --example jwt_periodic_broadcast`
//! 5. enter browser console (CTRL+SHFT+K)
//! run `parseInt((new Date().getTime() + 1 * 60 * 1000)/1000)` and copy the result
//! it mean the token will expire in 1 minute
//! 6. add `exp` field with the previous number in the **PAYLOAD** text field
//! For example
//! {
//! "sub": "1234567890",
//! "name": "John Doe",
//! "admin": true,
//! "iat": 1516239022,
//! "exp": 1573596610
//! }
//! 7. copy the token from jwt.io **Encoded** text field
//! 8. `websocat ws://127.0.0.1:8080/ws/love --header="Authorization: Bearer ${TOKEN}"`
#[global_allocator]
static GLOBAL: bitwyre_ws_core::mimalloc::MiMalloc = bitwyre_ws_core::mimalloc::MiMalloc;

use bitwyre_ws_core::{init_log, jwt, run_periodic_websocket_service};
use bitwyre_ws_core::{AuthMode, AuthHeader, PeriodicWebsocketConfig, PeriodicWebsocketState};
use once_cell::sync::Lazy;
use std::{io, sync::Arc, time::Duration};

fn main() -> io::Result<()> {
init_log(true, None);
static STATE: Lazy<PeriodicWebsocketState> = Lazy::new(|| {
PeriodicWebsocketState::new(PeriodicWebsocketConfig {
binding_url: "0.0.0.0:8080".into(),
binding_path: "/ws/love".into(),
max_clients: 16384,
periodic_interval: Duration::from_millis(1000),
rapid_request_limit: Duration::from_millis(1000),
periodic_message_getter: Arc::new(&|| "love".into()),
auth: AuthMode::JWT {
auth_header: AuthHeader::default(),
signing_secret: include_bytes!("../public_key.der"),
algorithm: jwt::SignatureAlgorithm::RS256,
validate: jwt::ClaimCode {
exp: true,
..Default::default()
},
},
})
});
run_periodic_websocket_service(Arc::new(&STATE))
}
65 changes: 65 additions & 0 deletions src/auth/jwt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
pub use biscuit::jwa::SignatureAlgorithm;

use super::{ActixResult, ErrorUnauthorized};
use crate::info;
use biscuit::{jws::Secret, Empty, Validation, ValidationOptions, JWT};

#[derive(Clone, Default)]
pub struct ClaimCode {
pub nbf: bool,
pub exp: bool,
}

impl ClaimCode {
pub fn disable_all() -> Self {
Self::default()
}

pub(crate) fn validate(&self, secret: &[u8], token: &str) -> ActixResult<()> {
let token = JWT::<Empty, Empty>::new_encoded(token);
let secret = Secret::PublicKey(secret.to_vec());

let token = token.into_decoded(&secret, SignatureAlgorithm::RS256).map_err(ErrorUnauthorized)?;
let claims = &token.payload().map_err(ErrorUnauthorized)?.registered;

let is_error = if claims.not_before.is_none() && self.nbf {
info!("Client connection unauthorized because `nbf` claims code not found");
true
} else if claims.expiry.is_none() && self.exp {
info!("Client connection unauthorized because `exp` claims code not found");
true
} else {
false
};
if is_error {
return Err(ErrorUnauthorized("wrong token"));
}

let with_options = ValidationOptions {
not_before: self.nbf.into_validation(),
expiry: self.exp.into_validation(),
..Default::default()
};
claims.validate(with_options).map_err(ErrorUnauthorized)?;
if let Some(timestamp) = claims.not_before {
info!("Client connection authorized not before {}", timestamp.to_rfc3339());
}
if let Some(timestamp) = claims.expiry {
info!("Client connection authorized expire at {}", timestamp.to_rfc3339());
}
Ok(())
}
}

trait IntoValidation<T> {
fn into_validation(self) -> Validation<T>;
}
impl IntoValidation<()> for bool {
fn into_validation(self) -> Validation<()> {
if self {
Validation::Validate(())
} else {
Validation::Ignored
}
}
}
124 changes: 124 additions & 0 deletions src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
pub(super) use crate::actix_web::Result as ActixResult;
use crate::actix_web::{error::ErrorUnauthorized, HttpRequest};
use actix_web::http::header::HeaderMap;

pub mod jwt;

#[derive(Clone)]
pub struct AuthHeader {
field: &'static str,
token_bound: (Option<&'static str>, Option<&'static str>),
}

impl AuthHeader {
/// return None if value is invalid or can't be parsed
pub fn new(field: &'static str, value: &'static str) -> Option<Self> {
let mut not_token = value.trim().split("{token}");
let token_bound = (
not_token.next().filter(|s| !s.is_empty()),
match not_token.next() {
None => return None,
Some(s) if s.is_empty() => None,
Some(s) => Some(s),
},
);
Some(Self { field, token_bound })
}
}

impl Default for AuthHeader {
fn default() -> Self {
AuthHeader::new("Authorization", "Bearer {token}").expect("has {token}")
}
}

#[derive(Clone)]
pub enum AuthMode {
JWT {
/** Header where the authentication token reside.\n
The format value is always be `... {token} ...`.\n
Default is `Authorization: Bearer {token}` */
auth_header: AuthHeader,
/** Bytes used for secret.
Use std::include_bytes!(from_file) for convinience */
signing_secret: &'static [u8],
validate: jwt::ClaimCode,
},
None,
}

impl Default for AuthMode {
fn default() -> Self {
Self::None
}
}

impl AuthMode {
pub fn default_jwt_from(signing_secret: &'static [u8]) -> Self {
Self::JWT {
auth_header: AuthHeader::new("Authorization", "Bearer {token}").expect("has {token}"),
validate: jwt::ClaimCode::disable_all(),
signing_secret,
}
}

pub(crate) fn validate(&self, request: &HttpRequest) -> ActixResult<()> {
match self {
Self::None => Ok(()),
Self::JWT {
auth_header: template,
validate: claim_code,
signing_secret: secret,
} => {
let token = extract_token(template, request.headers())?;
claim_code.validate(secret, token)
}
}
}
}

fn extract_token<'a>(template: &AuthHeader, header: &'a HeaderMap) -> ActixResult<&'a str> {
let header_value = header.get(template.field).ok_or_else(|| {
let message = ["Missing field '", template.field, "'"].concat();
ErrorUnauthorized(message)
})?;

let mut token = header_value.to_str().map_err(|e| ErrorUnauthorized(e.to_string()))?;
if let Some(non_token) = template.token_bound.0 {
token = token.trim_start_matches(non_token);
}
if let Some(non_token) = template.token_bound.1 {
token = token.trim_end_matches(non_token);
}
Ok(token)
}

#[cfg(test)]
mod unit_tests {
use super::*;
use std::error::Error;

#[test]
fn test_instantiate_auth_header() {
assert!(AuthHeader::new("Authorization", "Bearer token").is_none());
let authorization = |value| AuthHeader::new("Authorization", value).unwrap().token_bound;
assert_eq!((Some("Bearer "), None), authorization("Bearer {token}"));
assert_eq!((None, Some(" Key")), authorization("{token} Key"));
assert_eq!((Some("Bearer "), Some(" Key")), authorization("Bearer {token} Key"));
}

#[test]
fn test_extract_token() -> Result<(), Box<dyn Error>> {
const TOKEN: &str = include_str!("../../test/fixture/token_jwt.key");

let auth_header = AuthHeader::new("Authorization", "Bearer {token}").expect("has {token}");
let mut request_header = HeaderMap::new();

request_header.insert("API-Key".parse()?, "12345".parse()?);
request_header.insert("Authorization".parse()?, ["Bearer ", TOKEN].concat().parse()?);

assert_eq!(TOKEN, extract_token(&auth_header, &request_header)?);
assert!(extract_token(&auth_header, &HeaderMap::new()).is_err());
Ok(())
}
}
Loading

0 comments on commit 84be5ef

Please sign in to comment.