use crate::app::{AppBuilder, Plugin};
use crate::call::Call;
use crate::error::{Error, Result};
use crate::extract::FromCallParts;
use crate::pipeline::{Middleware, Next, Phase};
use crate::response::Response;
use crate::session::Session;
use async_trait::async_trait;
use http::header::LOCATION;
use http::{HeaderValue, StatusCode};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
const UID_KEY: &str = "__churust_uid";
const LOGIN_AT_KEY: &str = "__churust_lin";
const SEEN_AT_KEY: &str = "__churust_seen";
fn now_secs() -> Option<i64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64)
}
#[derive(Clone, Debug, Default)]
struct Policy {
login_url: Option<String>,
}
#[derive(Clone, Debug)]
pub struct Identity {
session: Session,
}
impl Identity {
pub fn id(&self) -> Option<String> {
self.session.get(UID_KEY)
}
pub fn is_authenticated(&self) -> bool {
self.id().is_some()
}
pub fn login(&self, id: impl Into<String>) {
self.session.rotate();
self.session.set(UID_KEY, id.into());
if let Some(now) = now_secs() {
self.session.set(LOGIN_AT_KEY, now.to_string());
self.session.set(SEEN_AT_KEY, now.to_string());
}
}
pub fn logout(&self) {
self.session.clear();
}
pub fn logged_in_at(&self) -> Option<i64> {
self.session.get(LOGIN_AT_KEY).and_then(|v| v.parse().ok())
}
pub fn last_seen_at(&self) -> Option<i64> {
self.session.get(SEEN_AT_KEY).and_then(|v| v.parse().ok())
}
}
#[async_trait]
impl FromCallParts for Identity {
async fn from_call_parts(call: &mut Call) -> Result<Self> {
Ok(Identity {
session: call.get::<Session>().unwrap_or_default(),
})
}
}
#[derive(Clone, Debug)]
pub struct Authenticated(
pub String,
);
#[async_trait]
impl FromCallParts for Authenticated {
async fn from_call_parts(call: &mut Call) -> Result<Self> {
let session = call.get::<Session>().unwrap_or_default();
if let Some(id) = session.get(UID_KEY) {
return Ok(Authenticated(id));
}
let policy = call.get::<Policy>().unwrap_or_default();
match policy.login_url {
Some(url) => {
let mut error = Error::new(StatusCode::SEE_OTHER, "authentication required");
if let Ok(value) = HeaderValue::from_str(&url) {
error = error.with_response_header(LOCATION, value);
}
Err(error)
}
None => Err(Error::new(
StatusCode::UNAUTHORIZED,
"authentication required",
)),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Identities {
login_deadline: Option<i64>,
visit_deadline: Option<i64>,
login_url: Option<String>,
}
impl Identities {
pub fn new() -> Self {
Self::default()
}
pub fn login_deadline(mut self, secs: i64) -> Self {
assert!(
secs > 0,
"login_deadline must be a positive number of seconds"
);
self.login_deadline = Some(secs);
self
}
pub fn visit_deadline(mut self, secs: i64) -> Self {
assert!(
secs > 0,
"visit_deadline must be a positive number of seconds"
);
self.visit_deadline = Some(secs);
self
}
pub fn login_url(mut self, url: impl Into<String>) -> Self {
self.login_url = Some(url.into());
self
}
}
impl Plugin for Identities {
fn install(self: Box<Self>, app: &mut AppBuilder) {
app.add_middleware_in(
Phase::Call,
Arc::new(IdentityMiddleware {
login_deadline: self.login_deadline,
visit_deadline: self.visit_deadline,
policy: Policy {
login_url: self.login_url.clone(),
},
}),
);
}
}
struct IdentityMiddleware {
login_deadline: Option<i64>,
visit_deadline: Option<i64>,
policy: Policy,
}
impl IdentityMiddleware {
fn expire(session: &Session) {
session.remove(UID_KEY);
session.remove(LOGIN_AT_KEY);
session.remove(SEEN_AT_KEY);
}
}
#[async_trait]
impl Middleware for IdentityMiddleware {
async fn handle(&self, mut call: Call, next: Next) -> Response {
call.insert(self.policy.clone());
if let (Some(session), Some(now)) = (call.get::<Session>(), now_secs()) {
if session.get(UID_KEY).is_some() {
let started: Option<i64> = session.get(LOGIN_AT_KEY).and_then(|v| v.parse().ok());
let seen: Option<i64> = session.get(SEEN_AT_KEY).and_then(|v| v.parse().ok());
let expired_absolute = self
.login_deadline
.zip(started)
.is_some_and(|(limit, at)| now.saturating_sub(at) >= limit);
let expired_idle = self
.visit_deadline
.zip(seen)
.is_some_and(|(limit, at)| now.saturating_sub(at) >= limit);
if expired_absolute || expired_idle {
Self::expire(&session);
} else if let Some(limit) = self.visit_deadline {
let granularity = (limit / 10).max(1);
let stale = seen.is_none_or(|at| now.saturating_sub(at) >= granularity);
if stale {
session.set(SEEN_AT_KEY, now.to_string());
}
}
}
}
next.run(call).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::Session;
fn identity() -> Identity {
Identity {
session: Session::default(),
}
}
#[test]
fn a_fresh_visitor_is_anonymous() {
let id = identity();
assert!(!id.is_authenticated());
assert_eq!(id.id(), None);
}
#[test]
fn login_records_who_and_when() {
let id = identity();
id.login("user-1");
assert_eq!(id.id().as_deref(), Some("user-1"));
assert!(id.is_authenticated());
assert!(id.logged_in_at().is_some());
assert!(id.last_seen_at().is_some());
}
#[test]
fn logout_empties_the_session() {
let id = identity();
id.session.set("cart", "3 items");
id.login("user-1");
id.logout();
assert!(!id.is_authenticated());
assert_eq!(
id.session.get("cart"),
None,
"logout drops everything, not only the identity"
);
}
#[test]
fn login_keeps_other_session_state() {
let id = identity();
id.session.set("cart", "3 items");
id.login("user-1");
assert_eq!(id.session.get("cart").as_deref(), Some("3 items"));
}
#[test]
fn expiring_leaves_the_rest_of_the_session_alone() {
let id = identity();
id.session.set("cart", "3 items");
id.login("user-1");
IdentityMiddleware::expire(&id.session);
assert!(!id.is_authenticated());
assert_eq!(id.session.get("cart").as_deref(), Some("3 items"));
}
#[test]
#[should_panic(expected = "login_deadline must be a positive")]
fn a_zero_login_deadline_is_refused() {
let _ = Identities::new().login_deadline(0);
}
#[test]
#[should_panic(expected = "visit_deadline must be a positive")]
fn a_negative_visit_deadline_is_refused() {
let _ = Identities::new().visit_deadline(-1);
}
}