Skip to content

Commit

Permalink
Merge pull request #128 from xsnippet/auth
Browse files Browse the repository at this point in the history
Add support for JWT-based authentication and authorization
  • Loading branch information
malor committed Apr 3, 2021
2 parents d21f967 + 296a1c8 commit ed8dc0b
Show file tree
Hide file tree
Showing 10 changed files with 1,486 additions and 23 deletions.
742 changes: 722 additions & 20 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ publish = false
[dependencies]
chrono = { version = "0.4.19", features = ["serde"] }
diesel = { version = "1.4.5", features = ["chrono", "postgres", "r2d2"] }
jsonwebtoken = "7.2.0"
rand = "0.7.3"
reqwest = { version = "0.11.2", features = ["blocking", "json"] }
rocket = "0.4.5"
rocket_contrib = {version = "0.4.5", features = ["json"]}
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1.25"
tracing-subscriber = "0.2.16"
uuid = { version = "0.8.2", features = ["v4"] }

[dev-dependencies]
tempfile = "3.2.0"
29 changes: 27 additions & 2 deletions src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::error::Error;

use super::routes;
use super::storage::{SqlStorage, Storage};
use super::web::{RequestIdHeader, RequestSpan};
use super::web::{AuthValidator, JwtValidator, RequestIdHeader, RequestSpan};

#[derive(Debug)]
pub struct Config {
Expand All @@ -13,6 +13,13 @@ pub struct Config {
/// order to ensure that the web part can properly syntax-highlight
/// snippets.
pub syntaxes: Option<BTreeSet<String>>,

/// The intended recipient of the tokens (e.g. "https://api.xsnippet.org")
pub jwt_audience: String,
/// The principal that issues the tokens (e.g. "https://xsnippet.eu.auth0.com/")
pub jwt_issuer: String,
/// The location of JWT Key Set with keys used to validate the tokens (e.g. "https://xsnippet.eu.auth0.com/.well-known/jwks.json")
pub jwt_jwks_uri: String,
}

/// Create and return a Rocket application instance.
Expand Down Expand Up @@ -40,16 +47,34 @@ pub fn create_app() -> Result<rocket::Rocket, Box<dyn Error>> {
Ok(database_url) => database_url,
Err(err) => return Err(Box::new(err)),
};

let config = Config {
syntaxes,
jwt_audience: app
.config()
.get_string("jwt_audience")
.unwrap_or_else(|_| String::from("https://api.xsnippet.org")),
jwt_issuer: app
.config()
.get_string("jwt_issuer")
.unwrap_or_else(|_| String::from("https://xsnippet.eu.auth0.com/")),
jwt_jwks_uri: app.config().get_string("jwt_jwks_uri").unwrap_or_else(|_| {
String::from("https://xsnippet.eu.auth0.com/.well-known/jwks.json")
}),
};

let storage: Box<dyn Storage> = Box::new(SqlStorage::new(&database_url)?);
let token_validator: Box<dyn AuthValidator> = Box::new(JwtValidator::from_config(&config)?);

let routes = routes![
routes::snippets::create_snippet,
routes::snippets::get_snippet,
routes::syntaxes::get_syntaxes,
];
Ok(app
.manage(Config { syntaxes })
.manage(config)
.manage(storage)
.manage(token_validator)
.attach(RequestIdHeader)
.attach(RequestSpan)
.mount("/v1", routes))
Expand Down
4 changes: 3 additions & 1 deletion src/routes/snippets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use serde::Deserialize;
use crate::application::Config;
use crate::errors::ApiError;
use crate::storage::{Changeset, Snippet, Storage};
use crate::web::{Input, NegotiatedContentType, Output};
use crate::web::{BearerAuth, Input, NegotiatedContentType, Output};

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
Expand Down Expand Up @@ -61,6 +61,7 @@ pub fn create_snippet(
origin: &Origin,
body: Result<Input<NewSnippet>, ApiError>,
_content_type: NegotiatedContentType,
_user: BearerAuth,
) -> Result<Created<Output<Snippet>>, ApiError> {
let new_snippet = storage.create(&body?.0.validate(config.syntaxes.as_ref())?)?;

Expand All @@ -72,6 +73,7 @@ pub fn create_snippet(
pub fn get_snippet(
storage: State<Box<dyn Storage>>,
id: String,
_user: BearerAuth,
) -> Result<Output<Snippet>, ApiError> {
Ok(Output(storage.get(&id)?))
}
2 changes: 2 additions & 0 deletions src/web.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod auth;
mod content;
mod tracing;

pub use crate::web::auth::{AuthValidator, BearerAuth, JwtValidator, User};
pub use crate::web::content::{Input, NegotiatedContentType, Output};
pub use crate::web::tracing::{RequestId, RequestIdHeader, RequestSpan};
106 changes: 106 additions & 0 deletions src/web/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
mod jwt;

use std::{error, fmt};

use rocket::http::Status;
use rocket::outcome::Outcome;
use rocket::request::{self, FromRequest, Request};
use serde::Deserialize;

pub use jwt::JwtValidator;

#[derive(Debug, PartialEq, Deserialize)]
pub enum Permission {
/// Allows superusers to perform any actions.
#[serde(rename(deserialize = "admin"))]
Admin,
}

#[derive(Debug, PartialEq)]
pub enum User {
/// Authenticated user. Can create, retrieve, update, and delete private
/// snippets. May have additional permissions (e.g. if this is an admin
/// user).
Authenticated {
name: String,
permissions: Vec<Permission>,
},

/// Anonymous user. Can create and retrieve publicly available snippets.
Guest,
}

#[derive(Debug)]
pub enum Error {
/// Server is misconfigured or the critical dependency is unavailable.
Configuration(String),

/// User input is malformed or incomplete.
Input(String),

/// Token validation failed (e.g. because it has expired or the signature is
/// incorrect).
Validation(String),
}

impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", *self)
}
}
impl error::Error for Error {}

pub type Result<T> = std::result::Result<T, Error>;

/// A common interface for validation of user tokens.
pub trait AuthValidator: Send + Sync {
/// Validate a given token value and determine permissions of the user.
fn validate(&self, token: &str) -> Result<User>;
}

/// A guard that performs authorization using the Bearer token passed in the
/// request headers. Expects the token validator to be configured as
/// rocket::State<Box<dyn Validator>>.
pub struct BearerAuth(pub User);

impl<'a, 'r> FromRequest<'a, 'r> for BearerAuth {
type Error = Error;

fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
match request.headers().get_one("Authorization") {
Some(value) => match value.split_once(' ') {
Some(("Bearer", token)) => {
if let Some(validator) = request
.guard::<rocket::State<Box<dyn AuthValidator>>>()
.succeeded()
{
match validator.validate(token) {
Ok(user) => Outcome::Success(BearerAuth(user)),
Err(err) => {
debug!("{}", err);

match err {
Error::Input(_) => Outcome::Failure((Status::BadRequest, err)),
_ => Outcome::Failure((Status::Forbidden, err)),
}
}
}
} else {
let err =
Error::Configuration("Token validator is not configured".to_string());
error!("{}", err);

Outcome::Failure((Status::ServiceUnavailable, err))
}
}
_ => {
let err = Error::Input(format!("Invalid Authorization header: {}", value));
debug!("{}", err);

Outcome::Failure((Status::BadRequest, err))
}
},
None => Outcome::Success(BearerAuth(User::Guest)),
}
}
}

0 comments on commit ed8dc0b

Please sign in to comment.