56ace537f3
req: security/003
240 lines
7.6 KiB
Markdown
240 lines
7.6 KiB
Markdown
# Recipe: auth/session and CSRF boundary for the SaaS tutorial
|
|
|
|
This recipe turns the `examples/saas` demo session into a production-shaped
|
|
application boundary without adding authentication, authorization, session, or
|
|
CSRF policy to hemx core. hemx receives a typed context and generated form
|
|
values; Axum/Tower middleware and extractors own cookies, credentials, and
|
|
rejection policy. req: laws/002 req: auth/001
|
|
|
|
Use this alongside `docs/recipes/sqlx-persistence.md`: authenticate the request,
|
|
verify CSRF for mutations, then call the application store and return generated
|
|
UI effects. req: auth/002 req: auth/004
|
|
|
|
## Boundary rule
|
|
|
|
Keep these concerns outside hemx crates:
|
|
|
|
- password or OAuth provider selection
|
|
- session cookie format, signing, storage, rotation, and expiration
|
|
- CSRF token minting, binding, and verification
|
|
- redirect vs HTTP error policy for non-enhanced requests
|
|
- role/permission checks
|
|
|
|
Keep these concerns inside normal app code:
|
|
|
|
- typed extractors such as `CurrentSession`
|
|
- app state such as `AppContext { session, store }`
|
|
- generated hemx form fields such as hidden `csrf`
|
|
- `Result<impl IntoEffect, AppError>` mapping for enhanced failures
|
|
|
|
The handler should read like ordinary Rust domain code, not framework magic.
|
|
|
|
## Axum state and session extractor
|
|
|
|
A real app would use a provider crate such as `tower-sessions`, `async-session`,
|
|
`axum-login`, or a custom signed-cookie middleware. The hemx boundary is the
|
|
same either way: produce a typed session before the handler runs.
|
|
|
|
```rust
|
|
use axum::extract::{FromRequestParts, State};
|
|
use axum::http::request::Parts;
|
|
use axum::response::{IntoResponse, Redirect, Response};
|
|
use std::sync::Arc;
|
|
|
|
#[derive(Clone)]
|
|
pub struct SecurityState {
|
|
sessions: Arc<dyn SessionStore>,
|
|
csrf: Arc<CsrfService>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct CurrentSession {
|
|
pub user_id: UserId,
|
|
pub email: String,
|
|
pub csrf: CsrfToken,
|
|
}
|
|
|
|
pub struct AuthRequired;
|
|
|
|
impl IntoResponse for AuthRequired {
|
|
fn into_response(self) -> Response {
|
|
Redirect::to("/login").into_response()
|
|
}
|
|
}
|
|
|
|
#[axum::async_trait]
|
|
impl FromRequestParts<AppState> for CurrentSession {
|
|
type Rejection = AuthRequired;
|
|
|
|
async fn from_request_parts(
|
|
parts: &mut Parts,
|
|
state: &AppState,
|
|
) -> Result<Self, Self::Rejection> {
|
|
let cookie = parts
|
|
.headers
|
|
.get(axum::http::header::COOKIE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.ok_or(AuthRequired)?;
|
|
|
|
state
|
|
.security
|
|
.sessions
|
|
.load(cookie)
|
|
.await
|
|
.ok_or(AuthRequired)
|
|
}
|
|
}
|
|
```
|
|
|
|
`CurrentSession` is an app extractor. It can be used in normal Axum routes, in
|
|
middleware, or copied into `AppContext` before dispatching hemx interactions.
|
|
hemx does not need to know how the session was loaded. req: auth/002
|
|
|
|
## CSRF token in the template
|
|
|
|
The template stays ordinary HTML: a hidden field plus normal cookie semantics.
|
|
The token value is a Rust field rendered by hemplate and parsed by the generated
|
|
form type. req: auth/003 req: auth/004 req: auth/005
|
|
|
|
```heml
|
|
<form data-hemx-handle="create_project" data-hemx-form="new_project">
|
|
<input type="hidden" name="csrf" +value="self.csrf">
|
|
<input name="name" required="required">
|
|
<button type="submit">Create project</button>
|
|
<p data-hemx-error-for="name"></p>
|
|
</form>
|
|
```
|
|
|
|
```rust
|
|
#[derive(Clone, Debug)]
|
|
#[hemx::form("new_project")]
|
|
pub struct NewProject {
|
|
csrf: CsrfToken,
|
|
name: ProjectName,
|
|
}
|
|
```
|
|
|
|
The browser submits the same form with or without the hemx runtime. Cookies,
|
|
SameSite behavior, and credential inclusion remain browser/framework concerns.
|
|
`hemx_axum::InteractionRequest` accepts only URL-encoded and multipart forms;
|
|
apply Axum's `DefaultBodyLimit` (or a compatible host limit) to every mutation
|
|
route. Media-type and size checks run before dispatch, while CSRF remains the
|
|
explicit application or middleware check shown below. req: security/003
|
|
|
|
## Mutation handler
|
|
|
|
Verify the session and CSRF token before persistence. Expected validation
|
|
returns a generated form effect; auth/CSRF failures return an application error
|
|
that maps to a generated UI effect or an HTTP response depending on the route.
|
|
req: form/001 req: failure/004
|
|
|
|
```rust
|
|
#[hemx::handler]
|
|
async fn create_project(
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<NewProject>,
|
|
) -> Result<impl IntoEffect, AppError> {
|
|
let session = ctx.session().ok_or(AppError::MissingSession)?;
|
|
ctx.csrf.verify(&session, &form.csrf)?;
|
|
|
|
if form.name.as_str().is_empty() {
|
|
return Err(AppError::Validation("Project name required"));
|
|
}
|
|
|
|
let project = ctx.store.insert(form.name, &session).await?;
|
|
let total = ctx.store.list().await?.len();
|
|
|
|
Ok((
|
|
dashboard::project_row.append(ProjectRow::from(project)),
|
|
dashboard::summary.set(project_summary(total)),
|
|
dashboard::new_project.clear(),
|
|
dashboard::flash.set("Project created"),
|
|
))
|
|
}
|
|
```
|
|
|
|
## Failure mapping
|
|
|
|
Keep policy in the app error type. Enhanced requests can render generated UI;
|
|
non-enhanced routes can redirect or return an HTTP status before hemx dispatch.
|
|
|
|
```rust
|
|
pub enum AppError {
|
|
MissingSession,
|
|
CsrfRejected,
|
|
Validation(&'static str),
|
|
StoreUnavailable,
|
|
}
|
|
|
|
impl IntoHandlerFailure for AppError {
|
|
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
|
|
match self {
|
|
Self::MissingSession => HandlerFailure::response(
|
|
axum::http::StatusCode::UNAUTHORIZED,
|
|
"Sign in to continue",
|
|
),
|
|
Self::CsrfRejected => HandlerFailure::effects(
|
|
dashboard::flash.set("Refresh the page before trying again"),
|
|
context,
|
|
),
|
|
Self::Validation(message) => HandlerFailure::effects(
|
|
(
|
|
dashboard::new_project.error("name", message),
|
|
dashboard::new_project.focus("name"),
|
|
),
|
|
context,
|
|
),
|
|
Self::StoreUnavailable => HandlerFailure::effects(
|
|
dashboard::flash.set("Project storage is temporarily unavailable"),
|
|
context,
|
|
),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
This keeps error policy explicit while preserving the same handler shape as the
|
|
local tutorial skeleton.
|
|
|
|
## Route wiring
|
|
|
|
For full-page routes, extract the session before rendering. For enhanced
|
|
interaction routes, build the app context from the extracted session and shared
|
|
application state, then dispatch the generated registry.
|
|
|
|
```rust
|
|
async fn home(
|
|
State(app): State<AppState>,
|
|
session: CurrentSession,
|
|
) -> impl IntoResponse {
|
|
Html(home_page(&AppContext::new(session, app.store.clone())).into_string())
|
|
}
|
|
|
|
async fn interact(
|
|
State(app): State<AppState>,
|
|
session: CurrentSession,
|
|
request: InteractionRequest,
|
|
) -> Result<EffectResponse, impl IntoResponse> {
|
|
let ctx = AppContext::new(session, app.store.clone());
|
|
request.dispatch_async(registry(ctx)).await
|
|
}
|
|
```
|
|
|
|
The same `AppContext` can contain a SQLx-backed store, an in-memory test store,
|
|
or a fake store for unit tests. hemx only observes the typed handler inputs and
|
|
the generated effects returned by the handler.
|
|
|
|
## Tests
|
|
|
|
Keep provider checks at the application boundary:
|
|
|
|
- request without a valid session is rejected before mutation
|
|
- stale CSRF token does not call the store
|
|
- valid session + CSRF stores the project and returns generated row/summary/form
|
|
effects
|
|
- validation failures target generated form errors, not selectors
|
|
|
|
`examples/saas` already has the local-store version of these checks; a provider
|
|
app should run the same interaction assertions with its real session/CSRF
|
|
middleware and store adapter. req: examples/001 req: test/001
|