use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("password hashing failed: {0}")]
Hash(String),
#[error("invalid or expired token")]
Token,
}
pub const ADMIN_ROLE: &str = "admin";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct OrgMembership {
pub org_id: Uuid,
pub role: Option<String>,
pub roles: Vec<String>,
}
impl OrgMembership {
pub fn new(
org_id: Uuid,
role: Option<String>,
extra: impl IntoIterator<Item = String>,
) -> Self {
let mut roles: Vec<String> = Vec::new();
for candidate in role.clone().into_iter().chain(extra) {
if !candidate.is_empty() && !roles.contains(&candidate) {
roles.push(candidate);
}
}
OrgMembership {
org_id,
role,
roles,
}
}
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|held| held == role) || self.is_admin()
}
pub fn is_admin(&self) -> bool {
self.roles.iter().any(|held| held == ADMIN_ROLE)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Principal {
pub user_id: Uuid,
pub organizations: Vec<OrgMembership>,
}
impl Principal {
pub fn membership(&self, org: Uuid) -> Option<&OrgMembership> {
self.organizations.iter().find(|m| m.org_id == org)
}
pub fn is_member(&self, org: Uuid) -> bool {
self.membership(org).is_some()
}
pub fn role_in(&self, org: Uuid) -> Option<&str> {
self.membership(org).and_then(|m| m.role.as_deref())
}
pub fn roles_in(&self, org: Uuid) -> &[String] {
self.membership(org)
.map(|m| m.roles.as_slice())
.unwrap_or(&[])
}
pub fn has_role_in(&self, org: Uuid, role: &str) -> bool {
self.membership(org).is_some_and(|m| m.has_role(role))
}
pub fn is_admin_of(&self, org: Uuid) -> bool {
self.membership(org).is_some_and(OrgMembership::is_admin)
}
pub fn org_ids(&self) -> Vec<Uuid> {
self.organizations.iter().map(|m| m.org_id).collect()
}
pub fn org_ids_with_role(&self, role: &str) -> Vec<Uuid> {
self.organizations
.iter()
.filter(|m| m.has_role(role))
.map(|m| m.org_id)
.collect()
}
}
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
exp: i64,
}
#[derive(Clone)]
pub struct Authenticator {
secret: Vec<u8>,
session_ttl_secs: i64,
}
impl Authenticator {
pub fn new(secret: impl Into<Vec<u8>>, session_ttl_secs: u64) -> Self {
Authenticator {
secret: secret.into(),
session_ttl_secs: session_ttl_secs as i64,
}
}
pub fn hash_password(&self, plaintext: &str) -> Result<String, Error> {
use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
use argon2::Argon2;
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(plaintext.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| Error::Hash(e.to_string()))
}
pub fn verify_password(&self, plaintext: &str, hash: &str) -> bool {
use argon2::password_hash::{PasswordHash, PasswordVerifier};
use argon2::Argon2;
match PasswordHash::new(hash) {
Ok(parsed) => Argon2::default()
.verify_password(plaintext.as_bytes(), &parsed)
.is_ok(),
Err(_) => false,
}
}
pub fn issue_token(&self, user_id: Uuid) -> Result<String, Error> {
use jsonwebtoken::{encode, EncodingKey, Header};
let exp = chrono::Utc::now().timestamp() + self.session_ttl_secs;
let claims = Claims {
sub: user_id.to_string(),
exp,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(&self.secret),
)
.map_err(|_| Error::Token)
}
pub fn verify_token(&self, token: &str) -> Result<Uuid, Error> {
use jsonwebtoken::{decode, DecodingKey, Validation};
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(&self.secret),
&Validation::default(),
)
.map_err(|_| Error::Token)?;
Uuid::parse_str(&data.claims.sub).map_err(|_| Error::Token)
}
pub fn generate_api_key(&self) -> (String, String) {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let plaintext = format!("apik_{}", hex::encode(bytes));
let hash = Self::hash_api_key(&plaintext);
(plaintext, hash)
}
pub fn hash_api_key(plaintext: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(plaintext.as_bytes());
hex::encode(hasher.finalize())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_roundtrip() {
let auth = Authenticator::new(b"secret".to_vec(), 3600);
let hash = auth.hash_password("hunter2").unwrap();
assert!(auth.verify_password("hunter2", &hash));
assert!(!auth.verify_password("wrong", &hash));
}
#[test]
fn token_roundtrip() {
let auth = Authenticator::new(b"secret".to_vec(), 3600);
let id = Uuid::new_v4();
let token = auth.issue_token(id).unwrap();
assert_eq!(auth.verify_token(&token).unwrap(), id);
}
#[test]
fn api_key_hash_is_deterministic() {
assert_eq!(
Authenticator::hash_api_key("apik_abc"),
Authenticator::hash_api_key("apik_abc")
);
}
#[test]
fn membership_lookup() {
let org = Uuid::new_v4();
let p = Principal {
user_id: Uuid::new_v4(),
organizations: vec![OrgMembership::new(org, Some("support".into()), [])],
};
assert!(p.is_member(org));
assert_eq!(p.role_in(org), Some("support"));
assert!(!p.is_member(Uuid::new_v4()));
assert_eq!(p.org_ids_with_role("support"), vec![org]);
assert!(p.org_ids_with_role("billing").is_empty());
}
#[test]
fn a_member_holds_every_role_granted_to_them() {
let org = Uuid::new_v4();
let p = Principal {
user_id: Uuid::new_v4(),
organizations: vec![OrgMembership::new(
org,
Some("support".into()),
["billing".to_string()],
)],
};
assert_eq!(p.roles_in(org), ["support", "billing"]);
assert!(p.has_role_in(org, "support"));
assert!(p.has_role_in(org, "billing"));
assert!(!p.has_role_in(org, "admin"));
assert_eq!(p.role_in(org), Some("support"));
}
#[test]
fn an_admin_holds_every_role_without_being_granted_them() {
let org = Uuid::new_v4();
let other = Uuid::new_v4();
let p = Principal {
user_id: Uuid::new_v4(),
organizations: vec![
OrgMembership::new(org, Some("admin".into()), []),
OrgMembership::new(other, Some("support".into()), []),
],
};
assert!(p.is_admin_of(org));
assert!(p.has_role_in(org, "billing"));
assert!(p.has_role_in(org, "anything-at-all"));
assert_eq!(p.org_ids_with_role("billing"), vec![org]);
assert!(!p.has_role_in(other, "billing"));
assert!(!p.is_admin_of(other));
assert_eq!(p.roles_in(org), ["admin"]);
}
#[test]
fn a_role_held_twice_is_still_one_role() {
let org = Uuid::new_v4();
let membership = OrgMembership::new(
org,
Some("admin".into()),
["admin".to_string(), "billing".to_string(), String::new()],
);
assert_eq!(membership.roles, ["admin", "billing"]);
}
#[test]
fn a_member_with_no_role_holds_none() {
let org = Uuid::new_v4();
let membership = OrgMembership::new(org, None, []);
assert!(membership.roles.is_empty());
assert!(!membership.is_admin());
assert!(!membership.has_role("member"));
}
}