Files
crank/apps/admin-api/src/auth.rs
T

253 lines
8.0 KiB
Rust

use axum::{
extract::{OriginalUri, Request, State},
http::{HeaderValue, Method, header},
middleware::Next,
response::Response,
};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use crank_community_auth::{
cleared_session_cookie as build_cleared_session_cookie, create_csrf_token as build_csrf_token,
create_session_cookie as build_session_cookie, hash_password as community_hash_password,
session_cookie as build_session_cookie_header,
};
use crank_core::{MembershipRole, User, UserSessionId, WorkspaceId};
use crank_registry::WorkspaceMembershipRecord;
use serde::Serialize;
use crate::{error::ApiError, state::AppState};
pub use crank_community_auth::{
SESSION_COOKIE_NAME, SessionCookie, create_csrf_token, extract_session_token, hash_csrf_token,
hash_session_secret, verify_password,
};
#[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>,
}
pub fn hash_password(password: &str, pepper: &str) -> Result<String, ApiError> {
community_hash_password(password, pepper)
.map_err(|error| ApiError::internal(format!("failed to hash password: {error}")))
}
pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, ApiError> {
build_session_cookie(settings.session_ttl_hours)
.map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}")))
}
pub fn create_csrf_token_value() -> String {
build_csrf_token()
}
pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> {
build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours)
}
pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> {
build_cleared_session_cookie(settings.cookie_secure)
}
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"));
}
if !matches!(
request.method(),
&axum::http::Method::GET | &axum::http::Method::HEAD
) && !session.memberships.iter().any(|membership| {
membership.workspace.id == workspace_id && membership.role == MembershipRole::Owner
}) {
return Err(ApiError::forbidden("workspace owner access required"));
}
request.extensions_mut().insert(session);
Ok(next.run(request).await)
}
pub async fn require_csrf_for_browser_mutations(
State(state): State<AppState>,
jar: CookieJar,
request: Request,
next: Next,
) -> Result<Response, ApiError> {
if !requires_csrf(request.method(), request.uri().path()) {
return Ok(next.run(request).await);
}
let (session_id, _session_value) = extract_session_token(&jar)
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
let token = request
.headers()
.get("x-csrf-token")
.and_then(|value| value.to_str().ok())
.filter(|value| valid_csrf_token(value))
.ok_or_else(|| ApiError::forbidden("csrf validation failed"))?;
let csrf_hash = hash_csrf_token(
&session_id,
token,
&state.service.auth_settings().session_secret,
);
if !state
.service
.verify_session_csrf(&session_id, &csrf_hash)
.await?
{
return Err(ApiError::forbidden("csrf validation failed"));
}
Ok(next.run(request).await)
}
pub async fn enforce_browser_security(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, ApiError> {
if let Some(origin) = request.headers().get(header::ORIGIN)
&& is_cross_origin(
origin,
request.headers().get(header::HOST),
if state.service.auth_settings().cookie_secure {
"https"
} else {
"http"
},
)
&& request.uri().path().starts_with("/api/")
{
return Err(ApiError::forbidden("cross-origin admin request denied"));
}
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
Ok(response)
}
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))
}
fn requires_csrf(method: &Method, path: &str) -> bool {
if matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) {
return false;
}
if matches!(
path,
"/api/auth/login" | "/api/auth/bootstrap/complete" | "/api/auth/session/csrf"
) {
return false;
}
path.starts_with("/api/auth/") || path.starts_with("/api/admin/")
}
fn valid_csrf_token(value: &str) -> bool {
(32..=256).contains(&value.len())
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}
fn is_cross_origin(
origin: &HeaderValue,
host: Option<&HeaderValue>,
expected_scheme: &str,
) -> bool {
let Some(host) = host.and_then(|value| value.to_str().ok()) else {
return true;
};
let Some(origin) = origin.to_str().ok() else {
return true;
};
let Ok(url) = url::Url::parse(origin) else {
return true;
};
url.host_str()
.zip(url.port_or_known_default())
.map(|(origin_host, origin_port)| {
let origin_authority = format!("{}://{origin_host}:{origin_port}", url.scheme());
let request_authority = if host.contains(':') {
format!("{expected_scheme}://{}", host.to_ascii_lowercase())
} else {
let default_port = if expected_scheme == "https" { 443 } else { 80 };
format!(
"{expected_scheme}://{}:{default_port}",
host.to_ascii_lowercase()
)
};
origin_authority.to_ascii_lowercase() != request_authority
})
.unwrap_or(true)
}