-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
16 changed files
with
356 additions
and
74 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,7 @@ | ||
# Project specifics | ||
*.pem | ||
*.der | ||
|
||
# Compiled files and executables | ||
/target | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(()) | ||
} | ||
} |
Oops, something went wrong.