use axum::{
Json,
extract::{FromRequestParts, State},
http::{StatusCode, header, request::Parts},
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
app::AppState,
audit,
error::ApiError,
model::UserRole,
session::{self, SESSION_COOKIE},
};
#[derive(Debug, Deserialize)]
pub struct Credentials {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct Identity {
pub id: Uuid,
pub email: String,
pub display_name: String,
pub role: UserRole,
}
#[derive(Debug, Clone)]
pub struct CurrentUser {
pub session_id: Uuid,
pub user_id: Uuid,
pub role: UserRole,
pub email: String,
}
impl FromRequestParts<AppState> for CurrentUser {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let token = session_cookie(parts).ok_or_else(|| ApiError::new(StatusCode::UNAUTHORIZED, "not signed in"))?;
let user = session::authenticate(&state.pool, &token)
.await
.map_err(|error| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
.ok_or_else(|| ApiError::new(StatusCode::UNAUTHORIZED, "the session has expired or been ended"))?;
Ok(Self {
session_id: user.session_id,
user_id: user.user_id,
role: user.role,
email: user.email,
})
}
}
impl CurrentUser {
pub fn require_admin(&self) -> Result<(), ApiError> {
if self.role == UserRole::Admin {
return Ok(());
}
Err(ApiError::new(StatusCode::FORBIDDEN, "not allowed"))
}
}
fn session_cookie(parts: &Parts) -> Option<String> {
let header = parts.headers.get(header::COOKIE)?.to_str().ok()?;
header.split(';').find_map(|pair| {
let (name, value) = pair.split_once('=')?;
(name.trim() == SESSION_COOKIE).then(|| value.trim().to_string())
})
}
pub async fn login(State(state): State<AppState>, Json(credentials): Json<Credentials>) -> Result<Response, ApiError> {
let row: Option<(Uuid, Option<String>)> = sqlx::query_as("SELECT id, password_hash FROM users WHERE lower(email) = lower($1) AND active")
.bind(&credentials.email)
.fetch_optional(&state.pool)
.await?;
let refused = || ApiError::new(StatusCode::UNAUTHORIZED, "wrong email or password");
let Some((user_id, Some(hash))) = row else {
session::verify_password(&credentials.password, DUMMY_HASH);
record_failure(&state.pool, &credentials.email).await;
return Err(refused());
};
if !session::verify_password(&credentials.password, &hash) {
record_failure(&state.pool, &credentials.email).await;
return Err(refused());
}
let issued = session::issue(&state.pool, user_id).await?;
tracing::info!(%user_id, "signed in");
audit::Entry::new(audit::action::LOGIN_SUCCEEDED)
.by(user_id)
.by_email(&credentials.email)
.on(user_id)
.record(&state.pool)
.await;
Ok((
StatusCode::OK,
[(header::SET_COOKIE, cookie_for(&issued.token, state.secure_cookies))],
Json(serde_json::json!({"status": "ok"})),
)
.into_response())
}
pub async fn logout(State(state): State<AppState>, user: CurrentUser) -> Result<Response, ApiError> {
session::revoke(&state.pool, user.session_id).await?;
Ok((
StatusCode::OK,
[(header::SET_COOKIE, expired_cookie(state.secure_cookies))],
Json(serde_json::json!({"status": "ok"})),
)
.into_response())
}
pub async fn logout_everywhere(State(state): State<AppState>, user: CurrentUser) -> Result<Response, ApiError> {
let ended = session::revoke_all(&state.pool, user.user_id).await?;
tracing::info!(user_id = %user.user_id, ended, "ended every session");
audit::Entry::new(audit::action::SESSIONS_ENDED)
.by(user.user_id)
.by_email(&user.email)
.on(user.user_id)
.with(serde_json::json!({"ended": ended}))
.record(&state.pool)
.await;
Ok((
StatusCode::OK,
[(header::SET_COOKIE, expired_cookie(state.secure_cookies))],
Json(serde_json::json!({"status": "ok", "ended": ended})),
)
.into_response())
}
pub async fn me(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
let display_name: String = sqlx::query_scalar("SELECT display_name FROM users WHERE id = $1")
.bind(user.user_id)
.fetch_one(&state.pool)
.await?;
Ok(Json(Identity {
id: user.user_id,
email: user.email.clone(),
display_name,
role: user.role,
}))
}
async fn record_failure(pool: &sqlx::PgPool, attempted_email: &str) {
audit::Entry::new(audit::action::LOGIN_FAILED).by_email(attempted_email).record(pool).await;
}
fn cookie_for(token: &str, secure: bool) -> String {
let max_age = session::SESSION_LIFETIME_DAYS * 24 * 60 * 60;
let secure = if secure { "; Secure" } else { "" };
format!("{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}{secure}")
}
fn expired_cookie(secure: bool) -> String {
let secure = if secure { "; Secure" } else { "" };
format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{secure}")
}
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols";
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderValue, Request, header::COOKIE};
fn parts_with(cookie: &str) -> Parts {
let mut request = Request::new(());
request.headers_mut().insert(COOKIE, HeaderValue::from_str(cookie).unwrap());
request.into_parts().0
}
#[test]
fn finds_our_cookie_among_the_others() {
assert_eq!(session_cookie(&parts_with("kasl_session=abc")).as_deref(), Some("abc"));
assert_eq!(
session_cookie(&parts_with("theme=dark; kasl_session=abc; lang=en")).as_deref(),
Some("abc"),
"a browser sends everything it has for the origin"
);
assert_eq!(session_cookie(&parts_with("kasl_session=abc ")).as_deref(), Some("abc"));
}
#[test]
fn ignores_cookies_that_are_not_ours() {
assert!(session_cookie(&parts_with("theme=dark")).is_none());
assert!(session_cookie(&parts_with("kasl_session_other=abc")).is_none());
}
#[test]
fn the_cookie_cannot_be_read_by_script_or_sent_across_sites() {
let cookie = cookie_for("token-value", true);
assert!(cookie.contains("HttpOnly"), "a readable token is a stealable token: {cookie}");
assert!(cookie.contains("SameSite=Strict"), "{cookie}");
assert!(cookie.contains("Secure"), "{cookie}");
assert!(cookie.contains("Max-Age=1209600"), "fourteen days in seconds: {cookie}");
}
#[test]
fn plain_http_gets_a_cookie_without_secure() {
let cookie = cookie_for("token-value", false);
assert!(!cookie.contains("Secure"), "{cookie}");
assert!(cookie.contains("HttpOnly"), "the rest of the protection stays: {cookie}");
}
#[test]
fn logging_out_clears_the_cookie() {
let cookie = expired_cookie(true);
assert!(cookie.contains("Max-Age=0"), "{cookie}");
assert!(cookie.starts_with("kasl_session=;"), "with no value left behind: {cookie}");
}
#[test]
fn the_dummy_hash_is_a_real_hash_that_matches_nothing() {
assert!(!crate::session::verify_password("", DUMMY_HASH));
assert!(!crate::session::verify_password("password", DUMMY_HASH));
}
#[test]
fn only_an_admin_passes_the_admin_check() {
let user = |role| CurrentUser {
session_id: Uuid::nil(),
user_id: Uuid::nil(),
role,
email: "someone@example.test".to_string(),
};
assert!(user(UserRole::Admin).require_admin().is_ok());
assert!(user(UserRole::Manager).require_admin().is_err());
assert!(user(UserRole::Employee).require_admin().is_err());
assert_eq!(
user(UserRole::Employee).require_admin().unwrap_err().to_string(),
"not allowed",
"the refusal must not confirm what the route is"
);
}
}