summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2024-09-04 01:25:31 -0400
committerOwen Jacobson <owen@grimoire.ca>2024-09-04 01:25:54 -0400
commit072dfa9a0bae5b7e9ea1caa97f6a90bd576a5d95 (patch)
tree3194c56bbf1b9729d07198973815c0cb88a9e5c6
parent2965a788cfcf4a0386cb8832e0d96491bf54c1d3 (diff)
Expire sessions after 90 days.
-rw-r--r--.sqlx/query-011ebe4604fb41aeec4330f5b66862611e6db9fd855fe12046a5c0576ca53d62.json12
-rw-r--r--src/cli.rs8
-rw-r--r--src/clock.rs51
-rw-r--r--src/lib.rs1
-rw-r--r--src/login/extract/login.rs3
-rw-r--r--src/login/repo/tokens.rs18
-rw-r--r--src/login/routes.rs6
7 files changed, 92 insertions, 7 deletions
diff --git a/.sqlx/query-011ebe4604fb41aeec4330f5b66862611e6db9fd855fe12046a5c0576ca53d62.json b/.sqlx/query-011ebe4604fb41aeec4330f5b66862611e6db9fd855fe12046a5c0576ca53d62.json
new file mode 100644
index 0000000..51462ff
--- /dev/null
+++ b/.sqlx/query-011ebe4604fb41aeec4330f5b66862611e6db9fd855fe12046a5c0576ca53d62.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete\n from token\n where issued_at < $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "011ebe4604fb41aeec4330f5b66862611e6db9fd855fe12046a5c0576ca53d62"
+}
diff --git a/src/cli.rs b/src/cli.rs
index 191e331..704c004 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,12 +1,12 @@
use std::io;
use std::str::FromStr;
-use axum::Router;
+use axum::{middleware, Router};
use clap::Parser;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use tokio::net;
-use crate::{error::BoxedError, index, login};
+use crate::{clock, error::BoxedError, index, login};
pub type Result<T> = std::result::Result<T, BoxedError>;
@@ -28,7 +28,9 @@ impl Args {
sqlx::migrate!().run(&pool).await?;
- let app = routers().with_state(pool);
+ let app = routers()
+ .route_layer(middleware::from_fn(clock::middleware))
+ .with_state(pool);
let listener = self.listener().await?;
let started_msg = started_msg(&listener)?;
diff --git a/src/clock.rs b/src/clock.rs
new file mode 100644
index 0000000..e53d825
--- /dev/null
+++ b/src/clock.rs
@@ -0,0 +1,51 @@
+use axum::{
+ extract::{Extension, FromRequestParts, Request},
+ http::{request::Parts, StatusCode},
+ middleware::Next,
+ response::Response,
+};
+use chrono::{DateTime, Utc};
+
+/// Extractor that provides the "current time" for a request. This time is calculated
+/// once per request, even if the extractor is used in multiple places. This requires
+/// the [middleware] function to be installed with [axum::middleware::from_fn] around
+/// the current route.
+#[derive(Clone)]
+pub struct RequestedAt(pub DateTime<Utc>);
+
+impl RequestedAt {
+ fn now() -> Self {
+ Self(Utc::now())
+ }
+
+ pub fn timestamp(&self) -> DateTime<Utc> {
+ self.0
+ }
+}
+
+#[async_trait::async_trait]
+impl<S> FromRequestParts<S> for RequestedAt
+where
+ S: Send + Sync,
+{
+ type Rejection = <Extension<RequestedAt> as FromRequestParts<S>>::Rejection;
+
+ async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
+ // This is purely for ergonomics: it allows `RequestedAt` to be extracted
+ // without having to wrap it in `Extension<>`. Callers _can_ still do that,
+ // but they aren't forced to.
+ let Extension(requested_at) =
+ Extension::<RequestedAt>::from_request_parts(parts, state).await?;
+
+ Ok(requested_at)
+ }
+}
+
+/// Computes a canonical "requested at" time for each request it wraps. This
+/// time can be recovered using the [RequestedAt] extractor.
+pub async fn middleware(mut req: Request, next: Next) -> Result<Response, StatusCode> {
+ let now = RequestedAt::now();
+ req.extensions_mut().insert(now);
+
+ Ok(next.run(req).await)
+}
diff --git a/src/lib.rs b/src/lib.rs
index c91ca43..80cf5a4 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,5 @@
pub mod cli;
+mod clock;
mod error;
mod id;
mod index;
diff --git a/src/login/extract/login.rs b/src/login/extract/login.rs
index f49933a..ce820f1 100644
--- a/src/login/extract/login.rs
+++ b/src/login/extract/login.rs
@@ -6,6 +6,7 @@ use axum::{
use sqlx::sqlite::SqlitePool;
use crate::{
+ clock::RequestedAt,
error::InternalError,
login::{
extract::IdentityToken,
@@ -22,11 +23,13 @@ impl FromRequestParts<SqlitePool> for Login {
state: &SqlitePool,
) -> Result<Self, Self::Rejection> {
let identity_token = IdentityToken::from_request_parts(parts, state).await?;
+ let requested_at = RequestedAt::from_request_parts(parts, state).await?;
let token = identity_token.token().ok_or(LoginError::Forbidden)?;
let db = State::<SqlitePool>::from_request_parts(parts, state).await?;
let mut tx = db.begin().await?;
+ tx.tokens().expire(requested_at.timestamp()).await?;
let login = tx.tokens().validate(token).await?;
tx.commit().await?;
diff --git a/src/login/repo/tokens.rs b/src/login/repo/tokens.rs
index 584f6dc..3ec3d63 100644
--- a/src/login/repo/tokens.rs
+++ b/src/login/repo/tokens.rs
@@ -1,3 +1,4 @@
+use chrono::TimeDelta;
use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction};
use uuid::Uuid;
@@ -62,6 +63,23 @@ impl<'c> Tokens<'c> {
Ok(())
}
+ pub async fn expire(&mut self, expire_at: DateTime) -> Result<(), BoxedError> {
+ // Somewhat arbitrarily, expire after 90 days.
+ let expired_issue_at = expire_at - TimeDelta::days(90);
+ sqlx::query!(
+ r#"
+ delete
+ from token
+ where issued_at < $1
+ "#,
+ expired_issue_at,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
/// Validate a token by its secret, retrieving the associated Login record.
/// Will return [None] if the token is not valid.
pub async fn validate(&mut self, secret: &str) -> Result<Option<Login>, BoxedError> {
diff --git a/src/login/routes.rs b/src/login/routes.rs
index a00982d..2269ea6 100644
--- a/src/login/routes.rs
+++ b/src/login/routes.rs
@@ -5,10 +5,9 @@ use axum::{
routing::post,
Router,
};
-use chrono::Utc;
use sqlx::sqlite::SqlitePool;
-use crate::error::InternalError;
+use crate::{clock::RequestedAt, error::InternalError};
use super::{
extract::IdentityToken,
@@ -29,11 +28,10 @@ struct Login {
async fn on_login(
State(db): State<SqlitePool>,
+ RequestedAt(now): RequestedAt,
identity: IdentityToken,
Form(form): Form<Login>,
) -> Result<impl IntoResponse, InternalError> {
- let now = Utc::now();
-
if identity.token().is_some() {
return Ok((StatusCode::BAD_REQUEST, identity, "already logged in"));
}