auth: extract community auth crate

This commit is contained in:
github-ops
2026-05-14 19:48:58 +00:00
parent 56268d1482
commit 7c2a8f4fac
13 changed files with 457 additions and 139 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "crank-community-auth"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[dependencies]
argon2.workspace = true
async-trait = "0.1"
axum-extra.workspace = true
base64.workspace = true
crank-core = { path = "../crank-core" }
crank-registry = { path = "../crank-registry" }
rand.workspace = true
sha2.workspace = true
thiserror.workspace = true
time.workspace = true
tracing.workspace = true
uuid.workspace = true
@@ -0,0 +1,61 @@
#[cfg(debug_assertions)]
use argon2::{Algorithm, Params, Version};
use argon2::{
Argon2,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
};
#[derive(Debug, thiserror::Error)]
pub enum HashPasswordError {
#[error("failed to initialize password hasher: {0}")]
Hasher(String),
#[error("failed to hash password: {0}")]
Hash(String),
}
pub fn hash_password(password: &str, pepper: &str) -> Result<String, HashPasswordError> {
let salt = SaltString::generate(&mut OsRng);
let password = format!("{password}{pepper}");
password_hasher()?
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|error| HashPasswordError::Hash(error.to_string()))
}
pub fn verify_password(password: &str, pepper: &str, password_hash: &str) -> bool {
let Ok(parsed) = PasswordHash::new(password_hash) else {
return false;
};
let password = format!("{password}{pepper}");
match password_hasher() {
Ok(argon2) => argon2.verify_password(password.as_bytes(), &parsed).is_ok(),
Err(_) => false,
}
}
fn password_hasher() -> Result<Argon2<'static>, HashPasswordError> {
#[cfg(debug_assertions)]
{
let params = Params::new(8 * 1024, 1, 1, None)
.map_err(|error| HashPasswordError::Hasher(error.to_string()))?;
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
}
#[cfg(not(debug_assertions))]
{
Ok(Argon2::default())
}
}
#[cfg(test)]
mod tests {
use super::{hash_password, verify_password};
#[test]
fn hashes_and_verifies_password() {
let password_hash = hash_password("test-password", "pepper").unwrap();
assert!(verify_password("test-password", "pepper", &password_hash));
assert!(!verify_password("wrong-password", "pepper", &password_hash));
}
}
+10
View File
@@ -0,0 +1,10 @@
pub mod hashing;
pub mod password_provider;
pub mod session_cookie;
pub use hashing::{HashPasswordError, hash_password, verify_password};
pub use password_provider::PasswordIdentityProvider;
pub use session_cookie::{
SESSION_COOKIE_NAME, SessionCookie, SessionCookieError, cleared_session_cookie,
create_session_cookie, extract_session_token, hash_session_secret, session_cookie,
};
@@ -0,0 +1,73 @@
use async_trait::async_trait;
use crank_core::{
AuthenticatedIdentity, IdentityError, IdentityProvider, IdentityProviderKind, LoginOutcome,
};
use crank_registry::PostgresRegistry;
use tracing::debug;
use crate::hashing::verify_password;
#[derive(Clone)]
pub struct PasswordIdentityProvider {
registry: PostgresRegistry,
password_pepper: String,
}
impl PasswordIdentityProvider {
pub fn new(registry: PostgresRegistry, password_pepper: impl Into<String>) -> Self {
Self {
registry,
password_pepper: password_pepper.into(),
}
}
}
#[async_trait]
impl IdentityProvider for PasswordIdentityProvider {
fn id(&self) -> &str {
"community-password"
}
fn kind(&self) -> IdentityProviderKind {
IdentityProviderKind::Password
}
async fn login_password(
&self,
payload: crank_core::LoginPayload,
) -> Result<LoginOutcome, IdentityError> {
let user = self
.registry
.get_auth_user_by_email(&payload.email)
.await
.map_err(|error| IdentityError::Internal(error.to_string()))?
.ok_or(IdentityError::BadCredentials)?;
if user.user.status != crank_core::UserStatus::Active {
return Err(IdentityError::AccountDisabled);
}
if !verify_password(
&payload.password,
&self.password_pepper,
&user.password_hash,
) {
debug!(email = %payload.email, "password identity provider rejected credentials");
return Err(IdentityError::BadCredentials);
}
let current_workspace_id = self
.registry
.list_workspaces_for_user(&user.user.id)
.await
.map_err(|error| IdentityError::Internal(error.to_string()))?
.first()
.map(|membership| membership.workspace.id.clone());
Ok(LoginOutcome::Authenticated(AuthenticatedIdentity {
user: user.user,
memberships: vec![],
current_workspace_id,
}))
}
}
@@ -0,0 +1,116 @@
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::UserSessionId;
use rand::RngCore;
use sha2::{Digest, Sha256};
use time::{Duration, OffsetDateTime};
pub const SESSION_COOKIE_NAME: &str = "crank_session";
#[derive(Clone)]
pub struct SessionCookie {
pub session_id: UserSessionId,
pub value: String,
pub expires_at: OffsetDateTime,
}
#[derive(Debug, thiserror::Error)]
pub enum SessionCookieError {
#[error("failed to compute session expiration")]
InvalidExpiration,
}
pub fn create_session_cookie(session_ttl_hours: i64) -> Result<SessionCookie, SessionCookieError> {
let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple()));
let mut secret_bytes = [0_u8; 32];
rand::thread_rng().fill_bytes(&mut secret_bytes);
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
let expires_at = OffsetDateTime::now_utc()
.checked_add(Duration::hours(session_ttl_hours))
.ok_or(SessionCookieError::InvalidExpiration)?;
Ok(SessionCookie {
session_id,
value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()),
expires_at,
})
}
pub fn hash_session_secret(
session_id: &UserSessionId,
session_value: &str,
session_secret: &str,
) -> String {
let mut digest = Sha256::new();
digest.update(session_id.as_str().as_bytes());
digest.update(b":");
digest.update(session_value.as_bytes());
digest.update(b":");
digest.update(session_secret.as_bytes());
URL_SAFE_NO_PAD.encode(digest.finalize())
}
pub fn session_cookie(token: &str, cookie_secure: bool, session_ttl_hours: i64) -> Cookie<'static> {
Cookie::build((SESSION_COOKIE_NAME, token.to_owned()))
.http_only(true)
.same_site(SameSite::Lax)
.secure(cookie_secure)
.path("/")
.max_age(Duration::hours(session_ttl_hours))
.build()
}
pub fn cleared_session_cookie(cookie_secure: bool) -> Cookie<'static> {
Cookie::build((SESSION_COOKIE_NAME, String::new()))
.http_only(true)
.same_site(SameSite::Lax)
.secure(cookie_secure)
.path("/")
.max_age(Duration::seconds(0))
.build()
}
pub fn extract_session_token(jar: &CookieJar) -> Option<(UserSessionId, String)> {
let cookie = jar.get(SESSION_COOKIE_NAME)?;
let value = cookie.value();
let (session_id, session_value) = value.split_once('.')?;
Some((UserSessionId::new(session_id), session_value.to_owned()))
}
#[cfg(test)]
mod tests {
use axum_extra::extract::cookie::CookieJar;
use super::{
SESSION_COOKIE_NAME, cleared_session_cookie, create_session_cookie, extract_session_token,
session_cookie,
};
#[test]
fn extracts_session_token_from_cookie_jar() {
let token = "sess_01.secret-value";
let jar = CookieJar::new().add(session_cookie(token, false, 24));
let (session_id, secret) = extract_session_token(&jar).unwrap();
assert_eq!(session_id.as_str(), "sess_01");
assert_eq!(secret, "secret-value");
}
#[test]
fn clears_session_cookie() {
let cookie = cleared_session_cookie(false);
assert_eq!(cookie.name(), SESSION_COOKIE_NAME);
assert_eq!(cookie.value(), "");
}
#[test]
fn creates_session_cookie_with_generated_values() {
let session = create_session_cookie(24).unwrap();
assert!(session.session_id.as_str().starts_with("sess_"));
assert!(session.value.contains('.'));
}
}