use std::error::Error;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
#[cfg(feature = "mail")]
mod account;
mod token;
pub use token::{Principal, bearer, token_role};
use super::request::State;
use super::{Next, Request, Response, Throttle, ToValue, Value, error, redirect};
use crate::crypto::Hash;
use crate::db::Db;
use crate::db::{Row, params};
const SESSION_KEY: &str = "_user";
const FINGERPRINT_KEY: &str = "_auth_fp";
const CONFIRMED_KEY: &str = "_auth_confirmed";
const INTENDED_KEY: &str = "_auth_intended";
const CONFIRM_FOR: u64 = 3 * 60 * 60;
const LOGIN: &str = "/login";
fn fingerprint(hash: &str) -> String {
crate::crypto::sha256(hash.as_bytes())[..32].to_owned()
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |time| time.as_secs())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
pub id: i64,
pub public_id: String,
pub name: String,
pub email: String,
pub role: String,
pub verified: bool,
}
impl User {
fn from_row(row: &Row) -> crate::db::Result<Self> {
Ok(Self {
id: row.get("id")?,
public_id: row.get("public_id")?,
name: row.get("name")?,
email: row.get("email")?,
role: row.get("role")?,
verified: row.get::<Option<String>>("email_verified_at")?.is_some(),
})
}
}
impl ToValue for User {
fn to_value(&self) -> Value {
Value::map(&[
("public_id", &self.public_id),
("name", &self.name),
("email", &self.email),
("role", &self.role),
("verified", &self.verified),
])
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refused {
Invalid,
Throttled(Duration),
Blocked,
}
pub struct Auth {
roles: &'static [&'static str],
throttle: Throttle,
#[cfg(feature = "mail")]
links: Option<account::Links>,
#[cfg(feature = "mail")]
mail_client: Throttle,
#[cfg(feature = "mail")]
mail_address: Throttle,
}
impl Auth {
pub fn new(roles: &'static [&'static str]) -> Self {
for required in ["super-admin", "user", "blocked"] {
assert!(roles.contains(&required), "roles must include {required}");
}
dummy_hash();
Self {
roles,
throttle: Throttle::per_minute(5),
#[cfg(feature = "mail")]
links: None,
#[cfg(feature = "mail")]
mail_client: Throttle::new(3, Duration::from_secs(60 * 60)),
#[cfg(feature = "mail")]
mail_address: Throttle::new(1, Duration::from_secs(10 * 60)),
}
}
pub fn from_config(roles: &'static [&'static str], config: &crate::config::Config) -> Self {
let trust = config.get_or("TRUST_PROXY", false);
#[cfg(not(feature = "mail"))]
let _ = trust;
Self {
throttle: Throttle::from_config(config, "LOGIN_PER_MINUTE", 5),
#[cfg(feature = "mail")]
links: Some(account::Links::from_config(config)),
#[cfg(feature = "mail")]
mail_client: Throttle::new(3, Duration::from_secs(60 * 60)).trust_forwarded(trust),
#[cfg(feature = "mail")]
mail_address: Throttle::new(1, Duration::from_secs(10 * 60)),
..Self::new(roles)
}
}
pub fn roles(&self) -> &'static [&'static str] {
self.roles
}
pub fn attempt(
&self,
request: &Request,
email: &str,
password: &str,
) -> Result<std::result::Result<User, Refused>, Box<dyn Error + Send + Sync>> {
let email = email.trim().to_lowercase();
let client = self.throttle.client(request);
if let Err(wait) = self.throttle.hit(&format!("{email}|{client}")) {
return Ok(Err(Refused::Throttled(wait)));
}
let db = request.db();
let found = db
.table("users")
.where_eq("email", &email)
.first(|row| Ok((User::from_row(row)?, row.get::<String>("password")?)))?;
let hash = found
.as_ref()
.map_or_else(|| dummy_hash(), |(_, hash)| hash.as_str());
let verified = Hash::check(password, hash);
let Some((user, stored)) = found.filter(|_| verified) else {
return Ok(Err(Refused::Invalid));
};
if user.role == "blocked" {
return Ok(Err(Refused::Blocked));
}
let session = request
.session()
.expect("login needs Sessions::middleware on this route");
session.regenerate();
session.put(SESSION_KEY, &user.id.to_string());
session.put(FINGERPRINT_KEY, &fingerprint(&stored));
Ok(Ok(user))
}
pub fn create_user(
&self,
db: &Db,
name: &str,
email: &str,
password: &str,
role: &str,
) -> Result<i64, Box<dyn Error + Send + Sync>> {
self.known(role)?;
Ok(db.table("users").insert(
&["public_id", "name", "email", "password", "role"],
params![
crate::uuid::Uuid::v7(),
name,
email.trim().to_lowercase(),
Hash::make(password),
role
],
)?)
}
pub fn sync_role(
&self,
db: &Db,
id: i64,
role: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
self.known(role)?;
db.table("users")
.where_eq("id", &id)
.update(&["role"], &[&role])?;
Ok(())
}
pub fn mark_verified(&self, db: &Db, id: i64) -> Result<(), Box<dyn Error + Send + Sync>> {
db.with(|sql| {
sql.execute(
"UPDATE users SET email_verified_at = CURRENT_TIMESTAMP
WHERE id = ?1 AND email_verified_at IS NULL",
&[&id],
)
})?;
Ok(())
}
pub fn confirm(
&self,
request: &Request,
password: &str,
) -> Result<Option<String>, Box<dyn Error + Send + Sync>> {
let (Some(user), Some(session)) = (request.user(), request.session()) else {
return Ok(None);
};
let hash: Option<String> = request
.db()
.table("users")
.where_eq("id", &user.id)
.first(|row| row.get("password"))?;
if !hash.is_some_and(|hash| Hash::check(password, &hash)) {
return Ok(None);
}
session.put(CONFIRMED_KEY, &unix_now().to_string());
Ok(Some(
session.take(INTENDED_KEY).unwrap_or_else(|| "/".into()),
))
}
pub fn at_least(&self, user: &User, role: &str) -> bool {
allows(user, |user| self.ranks_at_least(&user.role, role))
}
fn ranks_at_least(&self, held: &str, needed: &str) -> bool {
let rank = |slug: &str| self.roles.iter().position(|known| *known == slug);
matches!((rank(held), rank(needed)), (Some(held), Some(needed)) if held <= needed)
}
fn known(&self, role: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
if self.roles.contains(&role) {
Ok(())
} else {
Err(format!("unknown role {role}; the app's roles are {:?}", self.roles).into())
}
}
}
pub fn allows(user: &User, policy: impl FnOnce(&User) -> bool) -> bool {
allows_role(&user.role, || policy(user))
}
fn allows_role(role: &str, policy: impl FnOnce() -> bool) -> bool {
match role {
"blocked" => false,
"super-admin" => true,
_ => policy(),
}
}
pub fn logout(request: &Request) {
request
.session()
.expect("logout needs Sessions::middleware on this route")
.invalidate();
}
pub fn authenticate(request: &Request, next: Next) -> Response {
let Some(session) = request.session() else {
return next(request);
};
let Some(id) = session
.get(SESSION_KEY)
.and_then(|id| id.parse::<i64>().ok())
else {
return next(request);
};
let user = request
.db()
.table("users")
.where_eq("id", &id)
.first(|row| Ok((User::from_row(row)?, row.get::<String>("password")?)));
let current =
|hash: &str| session.get(FINGERPRINT_KEY).as_deref() == Some(fingerprint(hash).as_str());
match user {
Ok(Some((user, hash))) if user.role != "blocked" && current(&hash) => {
let mut request = request.clone();
let mut values = (*request.state.0).clone();
values.push(Arc::new(CurrentUser(user)));
request.state = State(Arc::new(values));
next(&request)
}
Ok(_) => {
session.invalidate();
next(request)
}
Err(problem) => {
crate::log::Log::error(format_args!("auth: could not load user {id}: {problem}"));
error(500)
}
}
}
pub fn verified(request: &Request, next: Next) -> Response {
match request.user() {
Some(user) if user.verified => next(request),
Some(_) => redirect("/email/verify"),
None => redirect(LOGIN),
}
}
pub fn password_confirmed(request: &Request, next: Next) -> Response {
let Some(session) = request.session() else {
return redirect(LOGIN);
};
let fresh = session
.get(CONFIRMED_KEY)
.and_then(|at| at.parse::<u64>().ok())
.is_some_and(|at| unix_now().saturating_sub(at) < CONFIRM_FOR);
if fresh {
return next(request);
}
if request.method == "GET" {
session.put(INTENDED_KEY, &request.path);
}
redirect("/confirm-password")
}
pub fn required(request: &Request, next: Next) -> Response {
match request.user() {
Some(_) => next(request),
None => redirect(LOGIN),
}
}
pub fn role(role: &'static str) -> impl Fn(&Request, Next) -> Response + Send + Sync + 'static {
move |request, next| match request.user() {
None => redirect(LOGIN),
Some(user) if request.auth().at_least(user, role) => next(request),
Some(_) => Response::text(403, ""),
}
}
struct CurrentUser(User);
impl Request {
pub fn user(&self) -> Option<&User> {
self.state::<CurrentUser>().map(|current| ¤t.0)
}
pub fn auth(&self) -> &Auth {
self.state::<Auth>()
.expect("no Auth: add .state(Auth::new(ROLES)) to the router")
}
}
fn dummy_hash() -> &'static str {
static DUMMY: OnceLock<String> = OnceLock::new();
DUMMY.get_or_init(|| Hash::make("not a real password"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::web::testing::Client;
use crate::web::{Router, Sessions, csrf};
const ROLES: &[&str] = &["super-admin", "admin", "user", "blocked"];
fn app() -> (Router, Db) {
let db = Db::open(&Config::parse("DB_DATABASE=:memory:"));
db.with(|sql| {
sql.execute_batch(
"CREATE TABLE users (id INTEGER PRIMARY KEY, public_id TEXT NOT NULL,
name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', email_verified_at TEXT)",
)
})
.unwrap();
let auth = Auth::new(ROLES);
for (name, role) in [
("root", "super-admin"),
("ada", "admin"),
("bob", "user"),
("eve", "blocked"),
] {
auth.create_user(&db, name, &format!(" {name}@X.si "), "secret-pass", role)
.unwrap();
}
let routes = Router::new().state(db.clone()).state(auth).group(|web| {
web.middleware(Sessions::new(Duration::from_secs(60)).middleware())
.middleware(csrf())
.middleware(authenticate)
.get("/login", |request| {
Response::new(200, "text/html", request.session().unwrap().csrf_field())
})
.post("/login", |request| {
let result = request
.auth()
.attempt(
request,
&request.form("email").unwrap_or_default(),
&request.form("password").unwrap_or_default(),
)
.unwrap();
match result {
Ok(user) => Response::text(200, &user.name),
Err(refused) => Response::text(422, &format!("{refused:?}")),
}
})
.post("/logout", |request| {
logout(request);
redirect("/")
})
.group(|admin| {
admin.middleware(role("admin")).get("/admin", |request| {
Response::text(200, &request.user().unwrap().name)
})
})
.group(|member| {
member
.middleware(required)
.get("/me", |_| Response::text(200, "me"))
})
});
(routes, db)
}
fn login(client: &Client<'_>, email: &str, password: &str) -> Response {
client.get("/login");
client.post("/login", &[("email", email), ("password", password)])
}
#[test]
fn guests_are_sent_to_login_and_roles_decide() {
let (routes, _) = app();
let guest = Client::new(&routes);
assert_eq!(guest.get("/admin").location(), Some("/login"));
assert_eq!(guest.get("/me").location(), Some("/login"));
for (who, admin) in [("root", 200), ("ada", 200), ("bob", 404)] {
let client = Client::new(&routes);
assert_eq!(
login(&client, &format!("{who}@x.si"), "secret-pass").status,
200,
"{who}"
);
assert_eq!(
client.get("/admin").status,
admin,
"{who}: a 403 is answered as 404"
);
assert_eq!(client.get("/me").status, 200, "{who}");
}
}
#[test]
fn wrong_password_and_unknown_email_look_the_same() {
let (routes, _) = app();
let client = Client::new(&routes);
let wrong = login(&client, "ada@x.si", "nope").body_text().into_owned();
let unknown = login(&client, "nobody@x.si", "nope")
.body_text()
.into_owned();
assert_eq!((wrong.as_str(), unknown.as_str()), ("Invalid", "Invalid"));
assert_eq!(
login(&client, " ADA@x.si ", "secret-pass").status,
200,
"email is case-insensitive"
);
}
#[test]
fn login_moves_the_session_and_logout_clears_it() {
let (routes, _) = app();
let client = Client::new(&routes);
client.get("/login");
let before = client.cookie("clamp_session");
login(&client, "ada@x.si", "secret-pass");
let after = client.cookie("clamp_session");
assert_ne!(before, after, "a new session ID on login");
client.get("/login");
assert_eq!(client.post("/logout", &[]).location(), Some("/"));
assert_ne!(
client.cookie("clamp_session"),
after,
"a new session ID on logout"
);
assert_eq!(
client.get("/me").location(),
Some("/login"),
"and logged out"
);
}
#[test]
fn blocked_users_cannot_log_in_and_are_logged_out_mid_session() {
let (routes, db) = app();
let client = Client::new(&routes);
assert_eq!(
login(&client, "eve@x.si", "secret-pass").body_text(),
"Blocked"
);
assert_eq!(
login(&client, "eve@x.si", "wrong").body_text(),
"Invalid",
"blocked only after the password"
);
let bob = Client::new(&routes);
login(&bob, "bob@x.si", "secret-pass");
assert_eq!(bob.get("/me").status, 200);
let id: i64 = db
.table("users")
.where_eq("email", &"bob@x.si")
.first(|row| row.get("id"))
.unwrap()
.unwrap();
routes_auth().sync_role(&db, id, "blocked").unwrap();
assert_eq!(
bob.get("/me").location(),
Some("/login"),
"blocked on the next request"
);
}
#[test]
fn login_attempts_are_throttled_per_email_and_address() {
let (routes, _) = app();
let client = Client::new(&routes);
for _ in 0..5 {
assert_eq!(login(&client, "ada@x.si", "nope").body_text(), "Invalid");
}
assert!(
login(&client, "ada@x.si", "secret-pass")
.body_text()
.starts_with("Throttled")
);
let elsewhere = Client::new(&routes).from("10.0.0.7");
assert_eq!(
login(&elsewhere, "ada@x.si", "secret-pass").status,
200,
"other addresses unaffected"
);
}
#[test]
fn roles_are_one_known_slug_and_the_before_rule_holds() {
let (_, db) = app();
let auth = routes_auth();
assert!(auth.create_user(&db, "x", "x@x.si", "p", "wizard").is_err());
assert!(auth.sync_role(&db, 1, "wizard").is_err());
let roles: Vec<String> = db.table("users").get(|row| row.get("role")).unwrap();
assert!(
roles.iter().all(|role| ROLES.contains(&role.as_str())),
"every user has one known role"
);
let user = |role: &str| User {
id: 1,
public_id: String::new(),
name: String::new(),
email: String::new(),
role: role.into(),
verified: true,
};
assert!(allows(&user("super-admin"), |_| false));
assert!(!allows(&user("blocked"), |_| true));
assert!(allows(&user("user"), |_| true) && !allows(&user("user"), |_| false));
assert!(!auth.at_least(&user("user"), "admin") && auth.at_least(&user("admin"), "user"));
}
#[test]
fn views_never_see_the_password_or_id() {
let (_, db) = app();
let user = db
.table("users")
.where_eq("email", &"ada@x.si")
.first(User::from_row)
.unwrap()
.unwrap();
let Value::Map(pairs) = user.to_value() else {
panic!("a map")
};
let keys: Vec<&str> = pairs.iter().map(|(key, _)| key.as_str()).collect();
assert_eq!(keys, ["public_id", "name", "email", "role", "verified"]);
}
fn routes_auth() -> Auth {
Auth::new(ROLES)
}
}