add session refresh (not great tho)

This commit is contained in:
Hamcha 2023-07-02 03:26:42 +02:00
parent 945afa0c43
commit 6badcd4ad8
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
8 changed files with 193 additions and 73 deletions

58
Cargo.lock generated
View file

@ -390,6 +390,21 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "futures"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.28" version = "0.3.28"
@ -406,6 +421,17 @@ version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c"
[[package]]
name = "futures-executor"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-intrusive" name = "futures-intrusive"
version = "0.4.2" version = "0.4.2"
@ -417,6 +443,23 @@ dependencies = [
"parking_lot 0.11.2", "parking_lot 0.11.2",
] ]
[[package]]
name = "futures-io"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964"
[[package]]
name = "futures-macro"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.28" version = "0.3.28"
@ -435,11 +478,16 @@ version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink", "futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"slab",
] ]
[[package]] [[package]]
@ -708,6 +756,7 @@ dependencies = [
"chrono", "chrono",
"cookie", "cookie",
"figment", "figment",
"futures",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
@ -1200,6 +1249,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "slab"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.10.0" version = "1.10.0"

View file

@ -19,6 +19,7 @@ chrono = { version = "0.4", features = ["serde", "clock"] }
anyhow = "1.0" anyhow = "1.0"
argon2 = { version = "0.5", features = ["std", "alloc"] } argon2 = { version = "0.5", features = ["std", "alloc"] }
url = "2.4" url = "2.4"
futures = "0.3"
[profile.dev.package.sqlx-macros] [profile.dev.package.sqlx-macros]
opt-level = 3 opt-level = 3

97
src/auth/http.rs Normal file
View file

@ -0,0 +1,97 @@
use anyhow::Result;
use axum::{
async_trait,
extract::{FromRequestParts, State},
http::{
header::{COOKIE, SET_COOKIE},
request::Parts,
HeaderValue, Request, StatusCode,
},
middleware::Next,
response::Response,
};
use chrono::{Duration, Utc};
use cookie::Cookie;
use sqlx::{Pool, Postgres};
use std::sync::Arc;
use uuid::Uuid;
use crate::{error::AppError, state::AppState};
use super::{session::Session, user::User};
pub const INVALID_SESSION: AppError = AppError::ClientError {
status: StatusCode::UNAUTHORIZED,
code: "authentication-required",
message: "Please log-in and submit a valid session as a cookie",
};
pub struct RequireSession(pub Session, pub User);
#[async_trait]
impl FromRequestParts<Arc<AppState>> for RequireSession {
type Rejection = AppError<'static>;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<RequireSession, Self::Rejection> {
if let Some(cookie) = parts.headers.get(COOKIE) {
let (session_id, session_secret) = extract_session_token(cookie)?;
match Session::find(&state.database, session_id).await? {
None => Err(INVALID_SESSION),
Some((session, user)) => {
if session.secret != session_secret {
return Err(INVALID_SESSION);
}
if session.expires_at < Utc::now().naive_utc() {
return Err(INVALID_SESSION);
}
Ok(RequireSession(session, user))
}
}
} else {
return Err(INVALID_SESSION);
}
}
}
fn extract_session_token(header: &HeaderValue) -> Result<(Uuid, String)> {
Ok(Session::parse_token(
Cookie::parse(header.to_str()?)?.value(),
)?)
}
async fn find_and_refresh(
pool: &Pool<Postgres>,
session_id: Uuid,
duration: Duration,
) -> Option<Session> {
if let Some(Some((session, _))) = Session::find(pool, session_id).await.ok() {
session.refresh(pool, duration).await.ok()
} else {
None
}
}
pub async fn refresh_sessions<B>(
State(state): State<Arc<AppState>>,
req: Request<B>,
next: Next<B>,
) -> Response {
if let Some((session_id, _)) = req
.headers()
.get(COOKIE)
.and_then(|header| extract_session_token(header).ok())
{
find_and_refresh(
&state.database,
session_id,
Duration::seconds(state.config.session_duration),
)
.await;
}
next.run(req).await
}

View file

@ -1,3 +1,4 @@
pub mod hash; pub mod hash;
pub mod http;
pub mod session; pub mod session;
pub mod user; pub mod user;

View file

@ -1,28 +1,16 @@
use std::sync::Arc;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use axum::{ use axum::http::StatusCode;
async_trait,
extract::{FromRequestParts, State},
http::{header::COOKIE, request::Parts, StatusCode},
};
use chrono::{Duration, NaiveDateTime, Utc}; use chrono::{Duration, NaiveDateTime, Utc};
use cookie::Cookie; use cookie::Cookie;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Pool, Postgres}; use sqlx::{FromRow, Pool, Postgres};
use uuid::Uuid; use uuid::Uuid;
use crate::{error::AppError, state::AppState}; use crate::error::AppError;
use super::{hash::random, user::User}; use super::{hash::random, user::User};
const INVALID_SESSION: AppError = AppError::ClientError { pub const USER_NOT_FOUND: AppError = AppError::ClientError {
status: StatusCode::UNAUTHORIZED,
code: "authentication-required",
message: "Please log-in and submit a valid session as a cookie",
};
const USER_NOT_FOUND: AppError = AppError::ClientError {
status: StatusCode::UNAUTHORIZED, status: StatusCode::UNAUTHORIZED,
code: "user-not-found", code: "user-not-found",
message: "The logged-in user was not found", message: "The logged-in user was not found",
@ -123,22 +111,16 @@ impl Session {
pub async fn refresh(self: Self, pool: &Pool<Postgres>, duration: Duration) -> Result<Self> { pub async fn refresh(self: Self, pool: &Pool<Postgres>, duration: Duration) -> Result<Self> {
let expires_at = (Utc::now() + duration).naive_utc(); let expires_at = (Utc::now() + duration).naive_utc();
let secret = random();
sqlx::query!( sqlx::query!(
"UPDATE sessions SET secret = $1, expires_at = $2 WHERE id = $3 RETURNING id", "UPDATE sessions SET expires_at = $1 WHERE id = $2 RETURNING id",
secret,
expires_at, expires_at,
self.id self.id
) )
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
Ok(Session { Ok(Session { expires_at, ..self })
secret,
expires_at,
..self
})
} }
pub fn token(self: &Self) -> String { pub fn token(self: &Self) -> String {
@ -161,39 +143,22 @@ impl Session {
None => Err(USER_NOT_FOUND), None => Err(USER_NOT_FOUND),
} }
} }
pub async fn prune_dead(pool: &Pool<Postgres>) -> Result<u64> {
let now = Utc::now().naive_utc();
let result = sqlx::query!("DELETE FROM sessions WHERE expires_at < $1", now)
.execute(pool)
.await?;
Ok(result.rows_affected())
} }
pub struct RequireSession(pub Session, pub User); pub fn cookie(self: &Self, domain: &str, secure: bool) -> String {
Cookie::build("session", self.token())
#[async_trait] .domain(domain)
impl FromRequestParts<Arc<AppState>> for RequireSession { .secure(secure)
type Rejection = AppError<'static>; .http_only(!secure)
.path("/")
async fn from_request_parts( .finish()
parts: &mut Parts, .to_string()
state: &Arc<AppState>,
) -> Result<RequireSession, Self::Rejection> {
if let Some(cookie) = parts.headers.get(COOKIE) {
let cookie_str = cookie.to_str()?;
let cookie = Cookie::parse(cookie_str)?;
let (session_id, session_secret) = Session::parse_token(cookie.value())?;
let State(state) = State::<Arc<AppState>>::from_request_parts(parts, state).await?;
match Session::find(&state.database, session_id).await? {
None => Err(INVALID_SESSION),
Some((session, user)) => {
if session.secret != session_secret {
return Err(INVALID_SESSION);
}
if session.expires_at < Utc::now().naive_utc() {
return Err(INVALID_SESSION);
}
Ok(RequireSession(session, user))
}
}
} else {
return Err(INVALID_SESSION);
}
} }
} }

View file

@ -5,8 +5,10 @@ mod roles;
mod routes; mod routes;
mod state; mod state;
use crate::{auth::http::refresh_sessions, state::Config};
use anyhow::Result; use anyhow::Result;
use axum::{ use axum::{
middleware,
routing::{get, post}, routing::{get, post},
Router, Server, Router, Server,
}; };
@ -18,8 +20,6 @@ use sqlx::postgres::PgPoolOptions;
use state::AppState; use state::AppState;
use std::{net::SocketAddr, sync::Arc}; use std::{net::SocketAddr, sync::Arc};
use crate::state::Config;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
@ -44,6 +44,10 @@ async fn main() -> Result<()> {
.route("/me", get(routes::auth::me)) .route("/me", get(routes::auth::me))
.route("/pages/:site/:slug", get(routes::content::page)) .route("/pages/:site/:slug", get(routes::content::page))
.route("/admin/bootstrap", post(routes::admin::bootstrap)) .route("/admin/bootstrap", post(routes::admin::bootstrap))
.route_layer(middleware::from_fn_with_state(
shared_state.clone(),
refresh_sessions,
))
.with_state(shared_state); .with_state(shared_state);
tracing::debug!("listening on {}", addr); tracing::debug!("listening on {}", addr);

View file

@ -5,17 +5,12 @@ use axum::{
Json, Json,
}; };
use chrono::Duration; use chrono::Duration;
use cookie::Cookie;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
auth::{ auth::{hash::verify, http::RequireSession, session::Session, user::User},
hash::verify,
session::{RequireSession, Session},
user::User,
},
error::AppError, error::AppError,
state::AppState, state::AppState,
}; };
@ -62,16 +57,13 @@ pub async fn login(
Json(json!({ "session_token": token, "expires_at": session.expires_at })).into_response(); Json(json!({ "session_token": token, "expires_at": session.expires_at })).into_response();
let secure = state.config.secure(); let secure = state.config.secure();
let cookie = Cookie::build("session", token)
.domain(state.config.domain())
.secure(secure)
.http_only(!secure)
.path("/")
.finish();
response response.headers_mut().insert(
.headers_mut() SET_COOKIE,
.insert(SET_COOKIE, cookie.to_string().parse()?); session
.cookie(state.config.domain().as_str(), secure)
.parse()?,
);
Ok(response) Ok(response)
} }

View file

@ -6,7 +6,8 @@ use url::Url;
pub struct Config { pub struct Config {
pub bind: String, pub bind: String,
pub database_url: String, pub database_url: String,
pub session_duration: i32, // in seconds pub session_duration: i64, // in seconds
pub prune_interval: u64, // in seconds
pub base_url: String, pub base_url: String,
} }
@ -31,6 +32,7 @@ impl Default for Config {
bind: "127.0.0.1:3000".into(), bind: "127.0.0.1:3000".into(),
database_url: "postgres://artificiale:changeme@localhost/artificiale".into(), database_url: "postgres://artificiale:changeme@localhost/artificiale".into(),
session_duration: 3600, // 60min session_duration: 3600, // 60min
prune_interval: 10, // 60min
base_url: "http://localhost".into(), base_url: "http://localhost".into(),
} }
} }