feat(saas): prove durable authenticated mutation
req: auth/001 req: auth/002 req: auth/004 req: security/004 req: v1_release/003
This commit is contained in:
+137
-6
@@ -9,6 +9,9 @@ use hemx_axum::{
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use std::fmt::Display;
|
||||
use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -19,6 +22,8 @@ pub struct Session {
|
||||
user_id: UserId,
|
||||
email: String,
|
||||
csrf: CsrfToken,
|
||||
origin: String,
|
||||
bearer: String,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -27,6 +32,8 @@ impl Session {
|
||||
user_id: UserId(42),
|
||||
email: "founder@example.com".to_owned(),
|
||||
csrf: CsrfToken("demo-csrf".to_owned()),
|
||||
origin: "http://127.0.0.1:3000".to_owned(),
|
||||
bearer: "Bearer demo-session".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,12 +98,56 @@ pub struct ProjectRecord {
|
||||
owner: String,
|
||||
}
|
||||
|
||||
impl ProjectRecord {
|
||||
fn encode(&self) -> String {
|
||||
format!("{}\t{}\t{}\n", self.id.0, self.owner, self.name)
|
||||
}
|
||||
|
||||
fn decode(line: &str) -> io::Result<Self> {
|
||||
let mut fields = line.splitn(3, '\t');
|
||||
let id = fields
|
||||
.next()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project id"))?;
|
||||
let owner = fields
|
||||
.next()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project owner"))?;
|
||||
let name = fields
|
||||
.next()
|
||||
.filter(|value| !value.is_empty() && !value.contains(['\n', '\r', '\t']))
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project name"))?;
|
||||
Ok(Self {
|
||||
id: ProjectId(id),
|
||||
name: name.to_owned(),
|
||||
owner: owner.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LocalProjectStore {
|
||||
projects: Arc<Mutex<Vec<ProjectRecord>>>,
|
||||
path: Option<Arc<PathBuf>>,
|
||||
}
|
||||
|
||||
impl LocalProjectStore {
|
||||
pub fn durable(path: impl Into<PathBuf>) -> io::Result<Self> {
|
||||
let path = path.into();
|
||||
let projects = match fs::read_to_string(&path) {
|
||||
Ok(contents) => contents
|
||||
.lines()
|
||||
.map(ProjectRecord::decode)
|
||||
.collect::<io::Result<Vec<_>>>()?,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
Ok(Self {
|
||||
projects: Arc::new(Mutex::new(projects)),
|
||||
path: Some(Arc::new(path)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert(&self, name: ProjectName, session: &Session) -> Result<ProjectRecord, AppError> {
|
||||
if name.as_str() == "fail-store" {
|
||||
return Err(AppError::StoreUnavailable);
|
||||
@@ -109,7 +160,12 @@ impl LocalProjectStore {
|
||||
name: name.as_str().to_owned(),
|
||||
owner: session.email.clone(),
|
||||
};
|
||||
projects.push(record.clone());
|
||||
let mut next = projects.clone();
|
||||
next.push(record.clone());
|
||||
if let Some(path) = self.path.as_deref() {
|
||||
persist_projects(path, &next).map_err(|_| AppError::StoreUnavailable)?;
|
||||
}
|
||||
*projects = next;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
@@ -118,6 +174,24 @@ impl LocalProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_projects(path: &Path, projects: &[ProjectRecord]) -> io::Result<()> {
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
fs::create_dir_all(parent)?;
|
||||
let temporary = path.with_extension("tmp");
|
||||
let mut file = fs::File::create(&temporary)?;
|
||||
for project in projects {
|
||||
file.write_all(project.encode().as_bytes())?;
|
||||
}
|
||||
file.sync_all()?;
|
||||
if let Err(error) = fs::rename(&temporary, path) {
|
||||
let _ = fs::remove_file(temporary);
|
||||
return Err(error);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fs::File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppContext {
|
||||
session: Session,
|
||||
@@ -132,6 +206,33 @@ impl AppContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn durable(path: impl Into<PathBuf>, origin: impl Into<String>) -> io::Result<Self> {
|
||||
let mut session = Session::demo();
|
||||
session.origin = origin.into();
|
||||
Ok(Self {
|
||||
session,
|
||||
store: LocalProjectStore::durable(path)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn authorize_mutation(
|
||||
&self,
|
||||
bearer: &str,
|
||||
csrf: &CsrfToken,
|
||||
origin: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if self.session.email.is_empty() || bearer != self.session.bearer {
|
||||
return Err(AppError::MissingSession);
|
||||
}
|
||||
if csrf != &self.session.csrf {
|
||||
return Err(AppError::CsrfRejected);
|
||||
}
|
||||
if origin != self.session.origin {
|
||||
return Err(AppError::OriginRejected);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn csrf(&self) -> &CsrfToken {
|
||||
&self.session.csrf
|
||||
}
|
||||
@@ -139,12 +240,37 @@ impl AppContext {
|
||||
pub fn projects(&self) -> Vec<ProjectRecord> {
|
||||
self.store.list()
|
||||
}
|
||||
|
||||
pub fn create_project_authorized(
|
||||
&self,
|
||||
name: &str,
|
||||
bearer: &str,
|
||||
csrf: &str,
|
||||
origin: &str,
|
||||
) -> Result<ProjectRecord, AppError> {
|
||||
let csrf = CsrfToken::from_str(csrf).expect("CSRF tokens are infallible strings");
|
||||
self.authorize_mutation(bearer, &csrf, origin)?;
|
||||
self.create_project(
|
||||
ProjectName::from_str(name).expect("project names are infallible strings"),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_project(&self, name: ProjectName) -> Result<ProjectRecord, AppError> {
|
||||
if name.as_str().is_empty() {
|
||||
return Err(AppError::Validation("Project name required"));
|
||||
}
|
||||
if name.as_str().len() > 100 || name.as_str().contains(['\n', '\r', '\t']) {
|
||||
return Err(AppError::Validation("Project name is invalid"));
|
||||
}
|
||||
self.store.insert(name, &self.session)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
MissingSession,
|
||||
CsrfRejected,
|
||||
OriginRejected,
|
||||
StoreUnavailable,
|
||||
Validation(&'static str),
|
||||
}
|
||||
@@ -154,12 +280,21 @@ impl AppError {
|
||||
match self {
|
||||
Self::MissingSession => "Sign in to continue",
|
||||
Self::CsrfRejected => "Refresh the page before creating another project",
|
||||
Self::OriginRejected => "Origin verification failed",
|
||||
Self::StoreUnavailable => "Project storage is temporarily unavailable",
|
||||
Self::Validation(message) => message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for AppError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AppError {}
|
||||
|
||||
impl IntoHandlerFailure for AppError {
|
||||
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
|
||||
match self {
|
||||
@@ -289,11 +424,7 @@ mod dashboard_handlers {
|
||||
if form.csrf != ctx.session.csrf {
|
||||
return Err(AppError::CsrfRejected);
|
||||
}
|
||||
if form.name.as_str().is_empty() {
|
||||
return Err(AppError::Validation("Project name required"));
|
||||
}
|
||||
|
||||
let project = ctx.store.insert(form.name, &ctx.session)?;
|
||||
let project = ctx.create_project(form.name)?;
|
||||
let total = ctx.projects().len();
|
||||
Ok((
|
||||
dashboard::project_row.append(ProjectRow::from(project)),
|
||||
|
||||
Reference in New Issue
Block a user