mirror of
https://github.com/zhaofengli/attic.git
synced 2024-12-14 11:57:30 +00:00
Refactor token into a separate crate
This commit is contained in:
parent
c89f5f0f3f
commit
77070b9895
9 changed files with 531 additions and 401 deletions
18
Cargo.lock
generated
18
Cargo.lock
generated
|
@ -199,6 +199,7 @@ dependencies = [
|
|||
"async-compression",
|
||||
"async-trait",
|
||||
"attic",
|
||||
"attic-token",
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
|
@ -218,7 +219,6 @@ dependencies = [
|
|||
"humantime-serde",
|
||||
"itoa",
|
||||
"jsonwebtoken",
|
||||
"lazy_static",
|
||||
"maybe-owned",
|
||||
"rand",
|
||||
"regex",
|
||||
|
@ -240,6 +240,22 @@ dependencies = [
|
|||
"xdg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attic-token"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"attic",
|
||||
"base64 0.20.0",
|
||||
"chrono",
|
||||
"displaydoc",
|
||||
"jsonwebtoken",
|
||||
"lazy_static",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_with",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
|
|
|
@ -4,4 +4,5 @@ members = [
|
|||
"attic",
|
||||
"client",
|
||||
"server",
|
||||
"token",
|
||||
]
|
||||
|
|
|
@ -20,6 +20,7 @@ doc = false
|
|||
|
||||
[dependencies]
|
||||
attic = { path = "../attic", default-features = false }
|
||||
attic-token = { path = "../token" }
|
||||
|
||||
anyhow = "1.0.68"
|
||||
async-trait = "0.1.60"
|
||||
|
@ -41,7 +42,6 @@ humantime = "2.1.0"
|
|||
humantime-serde = "1.1.1"
|
||||
itoa = "1.0.5"
|
||||
jsonwebtoken = "8.2.0"
|
||||
lazy_static = "1.4.0"
|
||||
maybe-owned = "0.3.4"
|
||||
rand = "0.8.5"
|
||||
regex = "1.7.0"
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
//! HTTP middlewares for access control.
|
||||
|
||||
use std::str;
|
||||
|
||||
use axum::{http::Request, middleware::Next, response::Response};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
|
@ -13,11 +9,7 @@ use crate::database::{entity::cache::CacheModel, AtticDatabase};
|
|||
use crate::error::ServerResult;
|
||||
use crate::{RequestState, State};
|
||||
use attic::cache::CacheName;
|
||||
|
||||
lazy_static! {
|
||||
static ref AUTHORIZATION_REGEX: Regex =
|
||||
Regex::new(r"^(?i)((?P<bearer>bearer)|(?P<basic>basic))(?-i) (?P<rest>(.*))$").unwrap();
|
||||
}
|
||||
use attic_token::util::parse_authorization_header;
|
||||
|
||||
/// Auth state.
|
||||
#[derive(Debug)]
|
||||
|
@ -124,46 +116,3 @@ pub async fn apply_auth<B>(req: Request<B>, next: Next<B>) -> Response {
|
|||
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Extracts the JWT from an Authorization header.
|
||||
fn parse_authorization_header(authorization: &str) -> Option<String> {
|
||||
let captures = AUTHORIZATION_REGEX.captures(authorization)?;
|
||||
let rest = captures.name("rest").unwrap().as_str();
|
||||
|
||||
if captures.name("bearer").is_some() {
|
||||
// Bearer token
|
||||
Some(rest.to_string())
|
||||
} else {
|
||||
// Basic auth
|
||||
let bytes = base64::decode(rest).ok()?;
|
||||
|
||||
let user_pass = str::from_utf8(&bytes).ok()?;
|
||||
let colon = user_pass.find(':')?;
|
||||
let pass = &user_pass[colon + 1..];
|
||||
|
||||
Some(pass.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_authorization_header() {
|
||||
assert_eq!(
|
||||
"somepass",
|
||||
parse_authorization_header("Basic c29tZXVzZXI6c29tZXBhc3M=").unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
"somepass",
|
||||
parse_authorization_header("baSIC c29tZXVzZXI6c29tZXBhc3M=").unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
"some-token",
|
||||
parse_authorization_header("bearer some-token").unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,352 +1,7 @@
|
|||
//! Access control.
|
||||
//!
|
||||
//! Access control in Attic is simple and stateless [0] - The server validates
|
||||
//! the JWT against a trusted public key and allows access based on the
|
||||
//! `x-attic-access` claim.
|
||||
//!
|
||||
//! One primary goal of the Attic Server is easy scalability. It's designed
|
||||
//! to be deployed to serverless platforms like AWS Lambda and have fast
|
||||
//! cold-start times. Instances are created and destoyed rapidly in response
|
||||
//! to requests.
|
||||
//!
|
||||
//! [0] We may revisit this later :)
|
||||
//!
|
||||
//! ## Cache discovery
|
||||
//!
|
||||
//! If the JWT grants any permission at all to the requested cache name,
|
||||
//! then the bearer is able to discover the presence of the cache, meaning
|
||||
//! that NoSuchCache or Forbidden can be returned depending on the scenario.
|
||||
//! Otherwise, the user will get a generic 401 response (Unauthorized)
|
||||
//! regardless of the request (or whether the cache exists or not).
|
||||
//!
|
||||
//! ## Supplying the token
|
||||
//!
|
||||
//! The JWT can be supplied to the server in one of two ways:
|
||||
//!
|
||||
//! - As a normal Bearer token.
|
||||
//! - As the password in Basic Auth (used by Nix). The username is ignored.
|
||||
//!
|
||||
//! To add the token to Nix, use the following format in `~/.config/nix/netrc`:
|
||||
//!
|
||||
//! ```text
|
||||
//! machine attic.server.tld password eyJhb...
|
||||
//! ```
|
||||
//!
|
||||
//! ## Example token
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "sub": "meow",
|
||||
//! "exp": 4102324986,
|
||||
//! "https://jwt.attic.rs/v1": {
|
||||
//! "caches": {
|
||||
//! "cache-rw": {
|
||||
//! "w": 1,
|
||||
//! "r": 1
|
||||
//! },
|
||||
//! "cache-ro": {
|
||||
//! "r": 1
|
||||
//! },
|
||||
//! "team-*": {
|
||||
//! "w": 1,
|
||||
//! "r": 1,
|
||||
//! "cc": 1
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//! See [attic_token] for more details.
|
||||
|
||||
pub mod http;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use displaydoc::Display;
|
||||
pub use jsonwebtoken::{
|
||||
Algorithm as JwtAlgorithm, DecodingKey as JwtDecodingKey, EncodingKey as JwtEncodingKey,
|
||||
Header as JwtHeader, Validation as JwtValidation,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, BoolFromInt};
|
||||
|
||||
use crate::error::ServerResult;
|
||||
use attic::cache::{CacheName, CacheNamePattern};
|
||||
|
||||
/// Custom claim namespace for the AtticAccess information.
|
||||
///
|
||||
/// Custom claim namespaces are required by platforms like Auth0, and
|
||||
/// custom claims without one will be silently dropped.
|
||||
///
|
||||
/// <https://auth0.com/docs/security/tokens/json-web-tokens/create-namespaced-custom-claims>
|
||||
///
|
||||
/// Also change the `#[serde(rename)]` below if you change this.
|
||||
pub const CLAIM_NAMESPACE: &str = "https://jwt.attic.rs/v1";
|
||||
|
||||
macro_rules! require_permission_function {
|
||||
($name:ident, $descr:literal, $member:ident) => {
|
||||
pub fn $name(&self) -> ServerResult<()> {
|
||||
if !self.$member {
|
||||
tracing::debug!("Client has no {} permission", $descr);
|
||||
if self.can_discover() {
|
||||
Err(Error::PermissionDenied.into())
|
||||
} else {
|
||||
Err(Error::NoDiscoveryPermission.into())
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// A validated JSON Web Token.
|
||||
#[derive(Debug)]
|
||||
pub struct Token(jsonwebtoken::TokenData<TokenClaims>);
|
||||
|
||||
/// Claims of a JSON Web Token.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TokenClaims {
|
||||
/// Subject.
|
||||
sub: String,
|
||||
|
||||
/// Expiration timestamp.
|
||||
exp: usize,
|
||||
|
||||
/// Attic namespace.
|
||||
#[serde(rename = "https://jwt.attic.rs/v1")]
|
||||
attic_ns: AtticAccess,
|
||||
}
|
||||
|
||||
/// Permissions granted to a client.
|
||||
///
|
||||
/// This is the content of the `attic-access` claim in JWTs.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AtticAccess {
|
||||
/// Cache permissions.
|
||||
///
|
||||
/// Keys here may include wildcards.
|
||||
caches: HashMap<CacheNamePattern, CachePermission>,
|
||||
}
|
||||
|
||||
/// Permission to a single cache.
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CachePermission {
|
||||
/// Can pull objects from the cache.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "r")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub pull: bool,
|
||||
|
||||
/// Can push objects to the cache.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "w")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub push: bool,
|
||||
|
||||
/// Can delete objects from the cache.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "d")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub delete: bool,
|
||||
|
||||
/// Can create the cache itself.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "cc")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub create_cache: bool,
|
||||
|
||||
/// Can reconfigure the cache.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "cr")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub configure_cache: bool,
|
||||
|
||||
/// Can configure retention/quota settings.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "cq")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub configure_cache_retention: bool,
|
||||
|
||||
/// Can destroy the cache itself.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "cd")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub destroy_cache: bool,
|
||||
}
|
||||
|
||||
/// An access error.
|
||||
#[derive(Debug, Display)]
|
||||
#[ignore_extra_doc_attributes]
|
||||
pub enum Error {
|
||||
/// User has no permission to this cache.
|
||||
NoDiscoveryPermission,
|
||||
|
||||
/// User does not have permission to complete this action.
|
||||
///
|
||||
/// This implies that there is some permission granted to the
|
||||
/// user, so the user is authorized to discover the cache.
|
||||
PermissionDenied,
|
||||
|
||||
/// JWT error: {0}
|
||||
TokenError(jsonwebtoken::errors::Error),
|
||||
}
|
||||
|
||||
impl Token {
|
||||
/// Verifies and decodes a token.
|
||||
pub fn from_jwt(token: &str, key: &JwtDecodingKey) -> ServerResult<Self> {
|
||||
let validation = JwtValidation::default();
|
||||
jsonwebtoken::decode::<TokenClaims>(token, key, &validation)
|
||||
.map_err(|e| Error::TokenError(e).into())
|
||||
.map(Token)
|
||||
}
|
||||
|
||||
/// Creates a new token with an expiration timestamp.
|
||||
pub fn new(sub: String, exp: &DateTime<Utc>) -> Self {
|
||||
let claims = TokenClaims {
|
||||
sub,
|
||||
exp: exp.timestamp() as usize,
|
||||
attic_ns: Default::default(),
|
||||
};
|
||||
|
||||
Self(jsonwebtoken::TokenData {
|
||||
header: JwtHeader::new(JwtAlgorithm::HS256),
|
||||
claims,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encodes the token.
|
||||
pub fn encode(&self, key: &JwtEncodingKey) -> ServerResult<String> {
|
||||
jsonwebtoken::encode(&self.0.header, &self.0.claims, key)
|
||||
.map_err(|e| Error::TokenError(e).into())
|
||||
}
|
||||
|
||||
/// Returns the subject of the token.
|
||||
pub fn sub(&self) -> &str {
|
||||
self.0.claims.sub.as_str()
|
||||
}
|
||||
|
||||
/// Returns the claims as a serializable value.
|
||||
pub fn opaque_claims(&self) -> &impl Serialize {
|
||||
&self.0.claims
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to a permission entry.
|
||||
pub fn get_or_insert_permission_mut(
|
||||
&mut self,
|
||||
pattern: CacheNamePattern,
|
||||
) -> &mut CachePermission {
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
let access = self.attic_access_mut();
|
||||
match access.caches.entry(pattern) {
|
||||
Entry::Occupied(v) => v.into_mut(),
|
||||
Entry::Vacant(v) => v.insert(CachePermission::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns explicit permission granted for a cache.
|
||||
pub fn get_permission_for_cache(&self, cache: &CacheName) -> CachePermission {
|
||||
let access = self.attic_access();
|
||||
|
||||
let pattern_key = cache.to_pattern();
|
||||
if let Some(direct_match) = access.caches.get(&pattern_key) {
|
||||
return direct_match.clone();
|
||||
}
|
||||
|
||||
for (pattern, permission) in access.caches.iter() {
|
||||
if pattern.matches(cache) {
|
||||
return permission.clone();
|
||||
}
|
||||
}
|
||||
|
||||
CachePermission::default()
|
||||
}
|
||||
|
||||
fn attic_access(&self) -> &AtticAccess {
|
||||
&self.0.claims.attic_ns
|
||||
}
|
||||
|
||||
fn attic_access_mut(&mut self) -> &mut AtticAccess {
|
||||
&mut self.0.claims.attic_ns
|
||||
}
|
||||
}
|
||||
|
||||
impl CachePermission {
|
||||
/// Adds implicit grants for public caches.
|
||||
pub fn add_public_permissions(&mut self) {
|
||||
self.pull = true;
|
||||
}
|
||||
|
||||
/// Returns whether the user is allowed to discover this cache.
|
||||
///
|
||||
/// This permission is implied when any permission is explicitly
|
||||
/// granted.
|
||||
pub const fn can_discover(&self) -> bool {
|
||||
self.push
|
||||
|| self.pull
|
||||
|| self.delete
|
||||
|| self.create_cache
|
||||
|| self.configure_cache
|
||||
|| self.destroy_cache
|
||||
|| self.configure_cache_retention
|
||||
}
|
||||
|
||||
pub fn require_discover(&self) -> ServerResult<()> {
|
||||
if !self.can_discover() {
|
||||
Err(Error::NoDiscoveryPermission.into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
require_permission_function!(require_pull, "pull", pull);
|
||||
require_permission_function!(require_push, "push", push);
|
||||
require_permission_function!(require_delete, "delete", delete);
|
||||
require_permission_function!(require_create_cache, "create cache", create_cache);
|
||||
require_permission_function!(
|
||||
require_configure_cache,
|
||||
"reconfigure cache",
|
||||
configure_cache
|
||||
);
|
||||
require_permission_function!(
|
||||
require_configure_cache_retention,
|
||||
"configure cache retention",
|
||||
configure_cache_retention
|
||||
);
|
||||
require_permission_function!(require_destroy_cache, "destroy cache", destroy_cache);
|
||||
|
||||
fn permission_default() -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CachePermission {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pull: false,
|
||||
push: false,
|
||||
delete: false,
|
||||
create_cache: false,
|
||||
configure_cache: false,
|
||||
configure_cache_retention: false,
|
||||
destroy_cache: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bruh
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!b
|
||||
}
|
||||
pub use attic_token::*;
|
||||
|
|
19
token/Cargo.toml
Normal file
19
token/Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "attic-token"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
attic = { path = "../attic", default-features = false }
|
||||
|
||||
base64 = "0.20.0"
|
||||
chrono = "0.4.23"
|
||||
displaydoc = "0.2.3"
|
||||
jsonwebtoken = "8.2.0"
|
||||
lazy_static = "1.4.0"
|
||||
regex = "1.7.0"
|
||||
serde = "1.0.151"
|
||||
serde_with = "2.1.0"
|
||||
tracing = "0.1.37"
|
362
token/src/lib.rs
Normal file
362
token/src/lib.rs
Normal file
|
@ -0,0 +1,362 @@
|
|||
//! Access control.
|
||||
//!
|
||||
//! Access control in Attic is simple and stateless [0] - The server validates
|
||||
//! the JWT against a trusted public key and allows access based on the
|
||||
//! `x-attic-access` claim.
|
||||
//!
|
||||
//! One primary goal of the Attic Server is easy scalability. It's designed
|
||||
//! to be deployed to serverless platforms like AWS Lambda and have fast
|
||||
//! cold-start times. Instances are created and destoyed rapidly in response
|
||||
//! to requests.
|
||||
//!
|
||||
//! [0] We may revisit this later :)
|
||||
//!
|
||||
//! ## Opaqueness
|
||||
//!
|
||||
//! The token format is unstable and claims beyond the standard ones defined
|
||||
//! in RFC 7519 should never be interpreted by the client. The token might not
|
||||
//! even be a valid JWT, in which case the client must not throw an error.
|
||||
//!
|
||||
//! ## Cache discovery
|
||||
//!
|
||||
//! If the JWT grants any permission at all to the requested cache name,
|
||||
//! then the bearer is able to discover the presence of the cache, meaning
|
||||
//! that NoSuchCache or Forbidden can be returned depending on the scenario.
|
||||
//! Otherwise, the user will get a generic 401 response (Unauthorized)
|
||||
//! regardless of the request (or whether the cache exists or not).
|
||||
//!
|
||||
//! ## Supplying the token
|
||||
//!
|
||||
//! The JWT can be supplied to the server in one of two ways:
|
||||
//!
|
||||
//! - As a normal Bearer token.
|
||||
//! - As the password in Basic Auth (used by Nix). The username is ignored.
|
||||
//!
|
||||
//! To add the token to Nix, use the following format in `~/.config/nix/netrc`:
|
||||
//!
|
||||
//! ```text
|
||||
//! machine attic.server.tld password eyJhb...
|
||||
//! ```
|
||||
//!
|
||||
//! ## Example token
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "sub": "meow",
|
||||
//! "exp": 4102324986,
|
||||
//! "https://jwt.attic.rs/v1": {
|
||||
//! "caches": {
|
||||
//! "cache-rw": {
|
||||
//! "w": 1,
|
||||
//! "r": 1
|
||||
//! },
|
||||
//! "cache-ro": {
|
||||
//! "r": 1
|
||||
//! },
|
||||
//! "team-*": {
|
||||
//! "w": 1,
|
||||
//! "r": 1,
|
||||
//! "cc": 1
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod util;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error as StdError;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use displaydoc::Display;
|
||||
pub use jsonwebtoken::{
|
||||
Algorithm as JwtAlgorithm, DecodingKey as JwtDecodingKey, EncodingKey as JwtEncodingKey,
|
||||
Header as JwtHeader, Validation as JwtValidation,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, BoolFromInt};
|
||||
|
||||
use attic::cache::{CacheName, CacheNamePattern};
|
||||
|
||||
/// Custom claim namespace for the AtticAccess information.
|
||||
///
|
||||
/// Custom claim namespaces are required by platforms like Auth0, and
|
||||
/// custom claims without one will be silently dropped.
|
||||
///
|
||||
/// <https://auth0.com/docs/security/tokens/json-web-tokens/create-namespaced-custom-claims>
|
||||
///
|
||||
/// Also change the `#[serde(rename)]` below if you change this.
|
||||
pub const CLAIM_NAMESPACE: &str = "https://jwt.attic.rs/v1";
|
||||
|
||||
macro_rules! require_permission_function {
|
||||
($name:ident, $descr:literal, $member:ident) => {
|
||||
pub fn $name(&self) -> Result<()> {
|
||||
if !self.$member {
|
||||
tracing::debug!("Client has no {} permission", $descr);
|
||||
if self.can_discover() {
|
||||
Err(Error::PermissionDenied)
|
||||
} else {
|
||||
Err(Error::NoDiscoveryPermission)
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// A validated JSON Web Token.
|
||||
#[derive(Debug)]
|
||||
pub struct Token(jsonwebtoken::TokenData<TokenClaims>);
|
||||
|
||||
/// Claims of a JSON Web Token.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TokenClaims {
|
||||
/// Subject.
|
||||
sub: String,
|
||||
|
||||
/// Expiration timestamp.
|
||||
exp: usize,
|
||||
|
||||
/// Attic namespace.
|
||||
#[serde(rename = "https://jwt.attic.rs/v1")]
|
||||
attic_ns: AtticAccess,
|
||||
}
|
||||
|
||||
/// Permissions granted to a client.
|
||||
///
|
||||
/// This is the content of the `attic-access` claim in JWTs.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AtticAccess {
|
||||
/// Cache permissions.
|
||||
///
|
||||
/// Keys here may include wildcards.
|
||||
caches: HashMap<CacheNamePattern, CachePermission>,
|
||||
}
|
||||
|
||||
/// Permission to a single cache.
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CachePermission {
|
||||
/// Can pull objects from the cache.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "r")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub pull: bool,
|
||||
|
||||
/// Can push objects to the cache.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "w")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub push: bool,
|
||||
|
||||
/// Can delete objects from the cache.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "d")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub delete: bool,
|
||||
|
||||
/// Can create the cache itself.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "cc")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub create_cache: bool,
|
||||
|
||||
/// Can reconfigure the cache.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "cr")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub configure_cache: bool,
|
||||
|
||||
/// Can configure retention/quota settings.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "cq")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub configure_cache_retention: bool,
|
||||
|
||||
/// Can destroy the cache itself.
|
||||
#[serde(default = "CachePermission::permission_default")]
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
#[serde(rename = "cd")]
|
||||
#[serde_as(as = "BoolFromInt")]
|
||||
pub destroy_cache: bool,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// An access error.
|
||||
#[derive(Debug, Display)]
|
||||
#[ignore_extra_doc_attributes]
|
||||
pub enum Error {
|
||||
/// User has no permission to this cache.
|
||||
NoDiscoveryPermission,
|
||||
|
||||
/// User does not have permission to complete this action.
|
||||
///
|
||||
/// This implies that there is some permission granted to the
|
||||
/// user, so the user is authorized to discover the cache.
|
||||
PermissionDenied,
|
||||
|
||||
/// JWT error: {0}
|
||||
TokenError(jsonwebtoken::errors::Error),
|
||||
}
|
||||
|
||||
impl Token {
|
||||
/// Verifies and decodes a token.
|
||||
pub fn from_jwt(token: &str, key: &JwtDecodingKey) -> Result<Self> {
|
||||
let validation = JwtValidation::default();
|
||||
jsonwebtoken::decode::<TokenClaims>(token, key, &validation)
|
||||
.map_err(|e| Error::TokenError(e))
|
||||
.map(Token)
|
||||
}
|
||||
|
||||
/// Creates a new token with an expiration timestamp.
|
||||
pub fn new(sub: String, exp: &DateTime<Utc>) -> Self {
|
||||
let claims = TokenClaims {
|
||||
sub,
|
||||
exp: exp.timestamp() as usize,
|
||||
attic_ns: Default::default(),
|
||||
};
|
||||
|
||||
Self(jsonwebtoken::TokenData {
|
||||
header: JwtHeader::new(JwtAlgorithm::HS256),
|
||||
claims,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encodes the token.
|
||||
pub fn encode(&self, key: &JwtEncodingKey) -> Result<String> {
|
||||
jsonwebtoken::encode(&self.0.header, &self.0.claims, key)
|
||||
.map_err(|e| Error::TokenError(e))
|
||||
}
|
||||
|
||||
/// Returns the subject of the token.
|
||||
pub fn sub(&self) -> &str {
|
||||
self.0.claims.sub.as_str()
|
||||
}
|
||||
|
||||
/// Returns the claims as a serializable value.
|
||||
pub fn opaque_claims(&self) -> &impl Serialize {
|
||||
&self.0.claims
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to a permission entry.
|
||||
pub fn get_or_insert_permission_mut(
|
||||
&mut self,
|
||||
pattern: CacheNamePattern,
|
||||
) -> &mut CachePermission {
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
let access = self.attic_access_mut();
|
||||
match access.caches.entry(pattern) {
|
||||
Entry::Occupied(v) => v.into_mut(),
|
||||
Entry::Vacant(v) => v.insert(CachePermission::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns explicit permission granted for a cache.
|
||||
pub fn get_permission_for_cache(&self, cache: &CacheName) -> CachePermission {
|
||||
let access = self.attic_access();
|
||||
|
||||
let pattern_key = cache.to_pattern();
|
||||
if let Some(direct_match) = access.caches.get(&pattern_key) {
|
||||
return direct_match.clone();
|
||||
}
|
||||
|
||||
for (pattern, permission) in access.caches.iter() {
|
||||
if pattern.matches(cache) {
|
||||
return permission.clone();
|
||||
}
|
||||
}
|
||||
|
||||
CachePermission::default()
|
||||
}
|
||||
|
||||
fn attic_access(&self) -> &AtticAccess {
|
||||
&self.0.claims.attic_ns
|
||||
}
|
||||
|
||||
fn attic_access_mut(&mut self) -> &mut AtticAccess {
|
||||
&mut self.0.claims.attic_ns
|
||||
}
|
||||
}
|
||||
|
||||
impl CachePermission {
|
||||
/// Adds implicit grants for public caches.
|
||||
pub fn add_public_permissions(&mut self) {
|
||||
self.pull = true;
|
||||
}
|
||||
|
||||
/// Returns whether the user is allowed to discover this cache.
|
||||
///
|
||||
/// This permission is implied when any permission is explicitly
|
||||
/// granted.
|
||||
pub const fn can_discover(&self) -> bool {
|
||||
self.push
|
||||
|| self.pull
|
||||
|| self.delete
|
||||
|| self.create_cache
|
||||
|| self.configure_cache
|
||||
|| self.destroy_cache
|
||||
|| self.configure_cache_retention
|
||||
}
|
||||
|
||||
pub fn require_discover(&self) -> Result<()> {
|
||||
if !self.can_discover() {
|
||||
Err(Error::NoDiscoveryPermission)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
require_permission_function!(require_pull, "pull", pull);
|
||||
require_permission_function!(require_push, "push", push);
|
||||
require_permission_function!(require_delete, "delete", delete);
|
||||
require_permission_function!(require_create_cache, "create cache", create_cache);
|
||||
require_permission_function!(
|
||||
require_configure_cache,
|
||||
"reconfigure cache",
|
||||
configure_cache
|
||||
);
|
||||
require_permission_function!(
|
||||
require_configure_cache_retention,
|
||||
"configure cache retention",
|
||||
configure_cache_retention
|
||||
);
|
||||
require_permission_function!(require_destroy_cache, "destroy cache", destroy_cache);
|
||||
|
||||
fn permission_default() -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CachePermission {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pull: false,
|
||||
push: false,
|
||||
delete: false,
|
||||
create_cache: false,
|
||||
configure_cache: false,
|
||||
configure_cache_retention: false,
|
||||
destroy_cache: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StdError for Error {}
|
||||
|
||||
// bruh
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!b
|
||||
}
|
76
token/src/tests.rs
Normal file
76
token/src/tests.rs
Normal file
|
@ -0,0 +1,76 @@
|
|||
use super::*;
|
||||
|
||||
use attic::cache::CacheName;
|
||||
|
||||
macro_rules! cache {
|
||||
($n:expr) => {
|
||||
CacheName::new($n.to_string()).unwrap()
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic() {
|
||||
// "very secure secret"
|
||||
let base64_secret = "dmVyeSBzZWN1cmUgc2VjcmV0";
|
||||
|
||||
let dec_key =
|
||||
JwtDecodingKey::from_base64_secret(base64_secret).expect("Could not import decoding key");
|
||||
|
||||
/*
|
||||
{
|
||||
"sub": "meow",
|
||||
"exp": 4102324986,
|
||||
"https://jwt.attic.rs/v1": {
|
||||
"caches": {
|
||||
"cache-rw": {"r":1,"w":1},
|
||||
"cache-ro": {"r":1},
|
||||
"team-*": {"r":1,"w":1,"cc":1}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtZW93IiwiZXhwIjo0MTAyMzI0OTg2LCJodHRwczovL2p3dC5hdHRpYy5ycy92MSI6eyJjYWNoZXMiOnsiY2FjaGUtcnciOnsiciI6MSwidyI6MX0sImNhY2hlLXJvIjp7InIiOjF9LCJ0ZWFtLSoiOnsiciI6MSwidyI6MSwiY2MiOjF9fX19.UlsIM9bQHr9SXGAcSQcoVPo9No8Zhh6Y5xfX8vCmKmA";
|
||||
|
||||
let decoded = Token::from_jwt(token, &dec_key).unwrap();
|
||||
|
||||
let perm_rw = decoded.get_permission_for_cache(&cache! { "cache-rw" });
|
||||
|
||||
assert!(perm_rw.pull);
|
||||
assert!(perm_rw.push);
|
||||
assert!(!perm_rw.delete);
|
||||
assert!(!perm_rw.create_cache);
|
||||
|
||||
assert!(perm_rw.require_pull().is_ok());
|
||||
assert!(perm_rw.require_push().is_ok());
|
||||
assert!(perm_rw.require_delete().is_err());
|
||||
assert!(perm_rw.require_create_cache().is_err());
|
||||
|
||||
let perm_ro = decoded.get_permission_for_cache(&cache! { "cache-ro" });
|
||||
|
||||
assert!(perm_ro.pull);
|
||||
assert!(!perm_ro.push);
|
||||
assert!(!perm_ro.delete);
|
||||
assert!(!perm_ro.create_cache);
|
||||
|
||||
assert!(perm_ro.require_pull().is_ok());
|
||||
assert!(perm_ro.require_push().is_err());
|
||||
assert!(perm_ro.require_delete().is_err());
|
||||
assert!(perm_ro.require_create_cache().is_err());
|
||||
|
||||
let perm_team = decoded.get_permission_for_cache(&cache! { "team-xyz" });
|
||||
|
||||
assert!(perm_team.pull);
|
||||
assert!(perm_team.push);
|
||||
assert!(!perm_team.delete);
|
||||
assert!(perm_team.create_cache);
|
||||
|
||||
assert!(perm_team.require_pull().is_ok());
|
||||
assert!(perm_team.require_push().is_ok());
|
||||
assert!(perm_team.require_delete().is_err());
|
||||
assert!(perm_team.require_create_cache().is_ok());
|
||||
|
||||
assert!(!decoded
|
||||
.get_permission_for_cache(&cache! { "forbidden-cache" })
|
||||
.can_discover());
|
||||
}
|
52
token/src/util.rs
Normal file
52
token/src/util.rs
Normal file
|
@ -0,0 +1,52 @@
|
|||
use std::str;
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
|
||||
lazy_static! {
|
||||
static ref AUTHORIZATION_REGEX: Regex =
|
||||
Regex::new(r"^(?i)((?P<bearer>bearer)|(?P<basic>basic))(?-i) (?P<rest>(.*))$").unwrap();
|
||||
}
|
||||
|
||||
/// Extracts the JWT from an Authorization header.
|
||||
pub fn parse_authorization_header(authorization: &str) -> Option<String> {
|
||||
let captures = AUTHORIZATION_REGEX.captures(authorization)?;
|
||||
let rest = captures.name("rest").unwrap().as_str();
|
||||
|
||||
if captures.name("bearer").is_some() {
|
||||
// Bearer token
|
||||
Some(rest.to_string())
|
||||
} else {
|
||||
// Basic auth
|
||||
let bytes = base64::decode(rest).ok()?;
|
||||
|
||||
let user_pass = str::from_utf8(&bytes).ok()?;
|
||||
let colon = user_pass.find(':')?;
|
||||
let pass = &user_pass[colon + 1..];
|
||||
|
||||
Some(pass.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_authorization_header() {
|
||||
assert_eq!(
|
||||
"somepass",
|
||||
parse_authorization_header("Basic c29tZXVzZXI6c29tZXBhc3M=").unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
"somepass",
|
||||
parse_authorization_header("baSIC c29tZXVzZXI6c29tZXBhc3M=").unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
"some-token",
|
||||
parse_authorization_header("bearer some-token").unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue