feat: complete Epic 1 production foundation
This commit is contained in:
+131
-3
@@ -1,22 +1,24 @@
|
||||
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,
|
||||
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::{User, UserSessionId, WorkspaceId};
|
||||
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, extract_session_token, hash_session_secret, verify_password,
|
||||
SESSION_COOKIE_NAME, SessionCookie, create_csrf_token, extract_session_token, hash_csrf_token,
|
||||
hash_session_secret, verify_password,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -53,6 +55,10 @@ pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, A
|
||||
.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)
|
||||
}
|
||||
@@ -90,11 +96,81 @@ pub async fn require_workspace_session(
|
||||
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,
|
||||
@@ -122,3 +198,55 @@ fn workspace_id_from_path(path: &str) -> Option<WorkspaceId> {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user