196 lines
6.1 KiB
Rust
196 lines
6.1 KiB
Rust
use argon2::{
|
|
Argon2,
|
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
|
};
|
|
use axum::{
|
|
extract::{OriginalUri, Request, State},
|
|
middleware::Next,
|
|
response::Response,
|
|
};
|
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
use crank_core::{User, UserSessionId, WorkspaceId};
|
|
use crank_registry::WorkspaceMembershipRecord;
|
|
use rand::RngCore;
|
|
use serde::Serialize;
|
|
use sha2::{Digest, Sha256};
|
|
use time::{Duration, OffsetDateTime};
|
|
|
|
use crate::{error::ApiError, state::AppState};
|
|
|
|
pub const SESSION_COOKIE_NAME: &str = "crank_session";
|
|
|
|
#[derive(Clone)]
|
|
pub struct BootstrapAdminConfig {
|
|
pub email: String,
|
|
pub password: String,
|
|
pub display_name: String,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct AuthSettings {
|
|
pub session_secret: String,
|
|
pub password_pepper: String,
|
|
pub session_ttl_hours: i64,
|
|
pub cookie_secure: bool,
|
|
pub bootstrap_admin: BootstrapAdminConfig,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AuthenticatedSession {
|
|
pub session_id: UserSessionId,
|
|
pub user: User,
|
|
pub memberships: Vec<WorkspaceMembershipRecord>,
|
|
pub current_workspace_id: Option<WorkspaceId>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct SessionCookie {
|
|
pub session_id: UserSessionId,
|
|
pub value: String,
|
|
pub expires_at: String,
|
|
}
|
|
|
|
pub fn hash_password(password: &str, pepper: &str) -> Result<String, ApiError> {
|
|
let salt = SaltString::generate(&mut OsRng);
|
|
let password = format!("{password}{pepper}");
|
|
Argon2::default()
|
|
.hash_password(password.as_bytes(), &salt)
|
|
.map(|hash| hash.to_string())
|
|
.map_err(|error| ApiError::internal(format!("failed to hash password: {error}")))
|
|
}
|
|
|
|
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}");
|
|
Argon2::default()
|
|
.verify_password(password.as_bytes(), &parsed)
|
|
.is_ok()
|
|
}
|
|
|
|
pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, ApiError> {
|
|
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(settings.session_ttl_hours))
|
|
.ok_or_else(|| ApiError::internal("failed to compute session expiration"))?;
|
|
|
|
Ok(SessionCookie {
|
|
session_id,
|
|
value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()),
|
|
expires_at: expires_at
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.map_err(|error| {
|
|
ApiError::internal(format!("failed to format session expiration: {error}"))
|
|
})?,
|
|
})
|
|
}
|
|
|
|
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(settings: &AuthSettings, token: &str) -> Cookie<'static> {
|
|
Cookie::build((SESSION_COOKIE_NAME, token.to_owned()))
|
|
.http_only(true)
|
|
.same_site(SameSite::Lax)
|
|
.secure(settings.cookie_secure)
|
|
.path("/")
|
|
.max_age(Duration::hours(settings.session_ttl_hours))
|
|
.build()
|
|
}
|
|
|
|
pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> {
|
|
Cookie::build((SESSION_COOKIE_NAME, String::new()))
|
|
.http_only(true)
|
|
.same_site(SameSite::Lax)
|
|
.secure(settings.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()))
|
|
}
|
|
|
|
pub async fn require_session(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
mut request: Request,
|
|
next: Next,
|
|
) -> Result<Response, ApiError> {
|
|
let session = resolve_authenticated_session(state.clone(), &jar).await?;
|
|
request.extensions_mut().insert(session);
|
|
Ok(next.run(request).await)
|
|
}
|
|
|
|
pub async fn require_workspace_session(
|
|
State(state): State<AppState>,
|
|
jar: CookieJar,
|
|
original_uri: OriginalUri,
|
|
mut request: Request,
|
|
next: Next,
|
|
) -> Result<Response, ApiError> {
|
|
let session = resolve_authenticated_session(state.clone(), &jar).await?;
|
|
let workspace_id = workspace_id_from_path(original_uri.path())
|
|
.ok_or_else(|| ApiError::internal("workspace middleware was applied to an invalid path"))?;
|
|
|
|
let has_access = state
|
|
.service
|
|
.user_has_workspace_access(&session.user.id, &workspace_id)
|
|
.await?;
|
|
if !has_access {
|
|
return Err(ApiError::forbidden("workspace access denied"));
|
|
}
|
|
|
|
request.extensions_mut().insert(session);
|
|
Ok(next.run(request).await)
|
|
}
|
|
|
|
async fn resolve_authenticated_session(
|
|
state: AppState,
|
|
jar: &CookieJar,
|
|
) -> Result<AuthenticatedSession, ApiError> {
|
|
let (session_id, session_value) = extract_session_token(jar)
|
|
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
|
|
let session = state
|
|
.service
|
|
.get_session(&session_id, &session_value)
|
|
.await?
|
|
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
|
|
|
|
state.service.touch_session(&session_id).await?;
|
|
|
|
Ok(session)
|
|
}
|
|
|
|
fn workspace_id_from_path(path: &str) -> Option<WorkspaceId> {
|
|
let marker = "/api/admin/workspaces/";
|
|
let (_, suffix) = path.split_once(marker)?;
|
|
let workspace_id = suffix.split('/').next()?;
|
|
if workspace_id.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(WorkspaceId::new(workspace_id))
|
|
}
|