use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::{Extension, Json};
use axum_extra::extract::PrivateCookieJar;
use axum_extra::extract::cookie::Cookie;
use kellnr_appstate::{AppState, DbState, LoginThrottleState, SettingsState, TokenCacheState};
use kellnr_auth::password::{PasswordAuthError, authenticate_password};
use kellnr_auth::token;
use kellnr_common::util::generate_rand_string;
use kellnr_db::{self, AuthToken, DbProvider, User};
use kellnr_settings::Settings;
use kellnr_settings::constants::{COOKIE_OIDC_ID_TOKEN, COOKIE_SESSION_ID, COOKIE_SESSION_USER};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::error::RouteError;
use crate::oauth2::OAuth2Ext;
use crate::session::{AdminUser, MaybeUser, create_session_jar};
fn ensure_local_auth_enabled(settings: &Settings) -> Result<(), RouteError> {
if settings.oauth2.enforced {
return Err(RouteError::Status(StatusCode::FORBIDDEN));
}
Ok(())
}
async fn revoke_sessions(
db: &dyn DbProvider,
user_name: &str,
keep_session_token: Option<&str>,
) -> Result<(), RouteError> {
match keep_session_token {
Some(token) => db.delete_other_user_sessions(user_name, token).await?,
None => db.delete_user_sessions(user_name).await?,
};
Ok(())
}
#[derive(Serialize, ToSchema)]
pub struct NewTokenResponse {
name: String,
token: String,
}
#[utoipa::path(
post,
path = "/me/tokens",
tag = "users",
request_body = token::NewTokenReqData,
responses(
(status = 200, description = "Token created successfully", body = NewTokenResponse),
(status = 401, description = "Not authenticated")
),
security(("session_cookie" = []))
)]
pub async fn add_token(
user: MaybeUser,
State(db): DbState,
State(cache): TokenCacheState,
Json(auth_token): Json<token::NewTokenReqData>,
) -> Result<Json<NewTokenResponse>, RouteError> {
let token = token::generate_token();
db.add_auth_token(&auth_token.name, &token, user.name())
.await?;
cache.invalidate_all();
Ok(NewTokenResponse {
name: auth_token.name.clone(),
token,
}
.into())
}
#[utoipa::path(
get,
path = "/me/tokens",
tag = "users",
responses(
(status = 200, description = "List of auth tokens", body = Vec<AuthToken>),
(status = 401, description = "Not authenticated")
),
security(("session_cookie" = []))
)]
pub async fn list_tokens(
user: MaybeUser,
State(db): DbState,
) -> Result<Json<Vec<AuthToken>>, RouteError> {
Ok(Json(db.get_auth_tokens(user.name()).await?))
}
#[utoipa::path(
get,
path = "/",
tag = "users",
responses(
(status = 200, description = "List of all users", body = Vec<User>),
(status = 403, description = "Admin access required")
),
security(("session_cookie" = []))
)]
pub async fn list_users(
_user: AdminUser,
State(db): DbState,
) -> Result<Json<Vec<User>>, RouteError> {
Ok(Json(db.get_users().await?))
}
#[utoipa::path(
delete,
path = "/me/tokens/{id}",
tag = "users",
params(
("id" = i32, Path, description = "Token ID to delete")
),
responses(
(status = 200, description = "Token deleted successfully"),
(status = 400, description = "Token not found"),
(status = 401, description = "Not authenticated")
),
security(("session_cookie" = []))
)]
pub async fn delete_token(
user: MaybeUser,
Path(id): Path<i32>,
State(db): DbState,
State(cache): TokenCacheState,
) -> Result<(), RouteError> {
db.get_auth_tokens(user.name())
.await?
.iter()
.find(|t| t.id == id)
.ok_or_else(|| RouteError::Status(StatusCode::BAD_REQUEST))?;
db.delete_auth_token(id).await?;
cache.invalidate_all();
Ok(())
}
#[derive(Serialize, ToSchema)]
pub struct ResetPwd {
new_pwd: String,
user: String,
}
#[utoipa::path(
put,
path = "/{name}/password",
tag = "users",
params(
("name" = String, Path, description = "Username")
),
responses(
(status = 200, description = "Password reset successfully", body = ResetPwd),
(status = 403, description = "Admin access required")
),
security(("session_cookie" = []))
)]
pub async fn reset_pwd(
admin: AdminUser,
jar: PrivateCookieJar,
Path(name): Path<String>,
State(db): DbState,
State(cache): TokenCacheState,
State(settings): SettingsState,
) -> Result<Json<ResetPwd>, RouteError> {
ensure_local_auth_enabled(&settings)?;
let new_pwd = generate_rand_string(12);
db.change_pwd(&name, &new_pwd).await?;
let keep_session = jar.get(COOKIE_SESSION_ID).filter(|_| admin.name() == name);
revoke_sessions(db.as_ref(), &name, keep_session.as_ref().map(Cookie::value)).await?;
cache.invalidate_all();
Ok(ResetPwd {
user: name,
new_pwd,
}
.into())
}
#[derive(Deserialize, ToSchema)]
pub struct ReadOnlyState {
pub state: bool,
}
#[utoipa::path(
post,
path = "/{name}/read-only",
tag = "users",
params(
("name" = String, Path, description = "Username")
),
request_body = ReadOnlyState,
responses(
(status = 200, description = "Read-only state changed successfully"),
(status = 400, description = "Cannot lock yourself"),
(status = 403, description = "Admin access required")
),
security(("session_cookie" = []))
)]
pub async fn read_only(
user: AdminUser,
Path(name): Path<String>,
State(db): DbState,
State(cache): TokenCacheState,
Json(ro_state): Json<ReadOnlyState>,
) -> Result<(), RouteError> {
if user.name() == name && ro_state.state {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
db.change_read_only_state(&name, ro_state.state).await?;
cache.invalidate_all();
Ok(())
}
#[derive(Deserialize, ToSchema)]
pub struct AdminState {
pub state: bool,
}
#[utoipa::path(
post,
path = "/{name}/admin",
tag = "users",
params(
("name" = String, Path, description = "Username")
),
request_body = AdminState,
responses(
(status = 200, description = "Admin state changed successfully"),
(status = 400, description = "Cannot demote yourself"),
(status = 403, description = "Admin access required")
),
security(("session_cookie" = []))
)]
pub async fn admin(
user: AdminUser,
Path(name): Path<String>,
State(db): DbState,
State(cache): TokenCacheState,
Json(admin_state): Json<AdminState>,
) -> Result<(), RouteError> {
if user.name() == name && !admin_state.state {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
db.change_admin_state(&name, admin_state.state).await?;
cache.invalidate_all();
Ok(())
}
#[utoipa::path(
delete,
path = "/{name}",
tag = "users",
params(
("name" = String, Path, description = "Username to delete")
),
responses(
(status = 200, description = "User deleted successfully"),
(status = 400, description = "Cannot delete yourself"),
(status = 403, description = "Admin access required")
),
security(("session_cookie" = []))
)]
pub async fn delete(
user: AdminUser,
Path(name): Path<String>,
State(db): DbState,
State(cache): TokenCacheState,
) -> Result<(), RouteError> {
if user.name() == name {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
db.delete_user(&name).await?;
cache.invalidate_all();
Ok(())
}
#[derive(Serialize, ToSchema)]
pub struct LoggedInUser {
user: String,
is_admin: bool,
is_logged_in: bool,
}
#[derive(Deserialize, ToSchema)]
pub struct Credentials {
pub user: String,
pub pwd: String,
}
impl Credentials {
pub fn validate(&self) -> Result<(), RouteError> {
if self.user.is_empty() {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
if self.pwd.is_empty() {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
Ok(())
}
}
#[utoipa::path(
post,
path = "/login",
tag = "auth",
request_body = Credentials,
responses(
(status = 200, description = "Successfully logged in", body = LoggedInUser),
(status = 400, description = "Invalid credentials"),
(status = 401, description = "Authentication failed")
)
)]
pub async fn login(
cookies: PrivateCookieJar,
State(state): AppState,
Json(credentials): Json<Credentials>,
) -> Result<(PrivateCookieJar, Json<LoggedInUser>), RouteError> {
ensure_local_auth_enabled(&state.settings)?;
credentials.validate()?;
let user = authenticate_password(
state.db.as_ref(),
&state.login_throttle,
&credentials.user,
&credentials.pwd,
)
.await
.map_err(|e| match e {
PasswordAuthError::Throttled => RouteError::Status(StatusCode::TOO_MANY_REQUESTS),
PasswordAuthError::Invalid => RouteError::AuthenticationFailure,
})?;
let jar = create_session_jar(cookies, &state, &credentials.user).await?;
Ok((
jar,
LoggedInUser {
user: credentials.user.clone(),
is_admin: user.is_admin,
is_logged_in: true,
}
.into(),
))
}
#[utoipa::path(
get,
path = "/state",
tag = "auth",
responses(
(status = 200, description = "Current login state", body = LoggedInUser)
)
)]
#[expect(clippy::unused_async)] pub async fn login_state(user: Option<MaybeUser>) -> Json<LoggedInUser> {
match user {
Some(MaybeUser::Normal(user)) => LoggedInUser {
user,
is_admin: false,
is_logged_in: true,
},
Some(MaybeUser::Admin(user)) => LoggedInUser {
user,
is_admin: true,
is_logged_in: true,
},
None => LoggedInUser {
user: String::new(),
is_admin: false,
is_logged_in: false,
},
}
.into()
}
#[derive(Serialize, ToSchema)]
pub struct LogoutResponse {
pub logout_url: Option<String>,
}
#[utoipa::path(
post,
path = "/logout",
tag = "auth",
responses(
(status = 200, description = "Successfully logged out", body = LogoutResponse)
)
)]
pub async fn logout(
mut jar: PrivateCookieJar,
State(state): AppState,
Extension(oauth2_handler): OAuth2Ext,
) -> Result<(PrivateCookieJar, Json<LogoutResponse>), RouteError> {
let session_id = match jar.get(COOKIE_SESSION_ID) {
Some(c) => c.value().to_owned(),
None => return Ok((jar, Json(LogoutResponse { logout_url: None }))),
};
let logout_url = jar
.get(COOKIE_OIDC_ID_TOKEN)
.zip(oauth2_handler.as_ref())
.and_then(|(id_token, handler)| {
let post_logout = format!("{}/", state.settings.origin.base_url());
handler.end_session_url(id_token.value(), &post_logout)
});
jar = jar.remove(Cookie::build((COOKIE_SESSION_ID, "")).path("/"));
jar = jar.remove(Cookie::build((COOKIE_SESSION_USER, "")).path("/"));
jar = jar.remove(Cookie::build((COOKIE_OIDC_ID_TOKEN, "")).path("/"));
state.db.delete_session_token(&session_id).await?;
Ok((jar, Json(LogoutResponse { logout_url })))
}
#[derive(Deserialize, ToSchema)]
pub struct PwdChange {
pub old_pwd: String,
pub new_pwd1: String,
pub new_pwd2: String,
}
impl PwdChange {
pub fn validate(&self) -> Result<(), RouteError> {
if self.old_pwd.is_empty() {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
if self.new_pwd1.is_empty() || self.new_pwd2.is_empty() {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
if self.new_pwd1 != self.new_pwd2 {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
Ok(())
}
}
#[utoipa::path(
put,
path = "/me/password",
tag = "users",
request_body = PwdChange,
responses(
(status = 200, description = "Password changed successfully"),
(status = 400, description = "Invalid password or validation failed"),
(status = 401, description = "Not authenticated")
),
security(("session_cookie" = []))
)]
pub async fn change_pwd(
user: MaybeUser,
jar: PrivateCookieJar,
State(db): DbState,
State(cache): TokenCacheState,
State(throttle): LoginThrottleState,
State(settings): SettingsState,
Json(pwd_change): Json<PwdChange>,
) -> Result<(), RouteError> {
ensure_local_auth_enabled(&settings)?;
pwd_change.validate()?;
let user = authenticate_password(db.as_ref(), &throttle, user.name(), &pwd_change.old_pwd)
.await
.map_err(|e| match e {
PasswordAuthError::Throttled => RouteError::Status(StatusCode::TOO_MANY_REQUESTS),
PasswordAuthError::Invalid => RouteError::Status(StatusCode::BAD_REQUEST),
})?;
db.change_pwd(&user.name, &pwd_change.new_pwd1).await?;
let current_session = jar.get(COOKIE_SESSION_ID);
revoke_sessions(
db.as_ref(),
&user.name,
current_session.as_ref().map(Cookie::value),
)
.await?;
cache.invalidate_all();
Ok(())
}
#[derive(Deserialize, ToSchema)]
pub struct NewUser {
pub pwd1: String,
pub pwd2: String,
pub name: String,
#[serde(default)] pub is_admin: bool,
#[serde(default)] pub is_read_only: bool,
}
impl NewUser {
pub fn validate(&self) -> Result<(), RouteError> {
if self.name.is_empty() {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
if self.pwd1.is_empty() || self.pwd2.is_empty() {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
if self.pwd1 != self.pwd2 {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
Ok(())
}
}
#[utoipa::path(
post,
path = "/",
tag = "users",
request_body = NewUser,
responses(
(status = 200, description = "User created successfully"),
(status = 400, description = "Validation failed"),
(status = 403, description = "Admin access required")
),
security(("session_cookie" = []))
)]
pub async fn add(
_user: AdminUser,
State(db): DbState,
State(cache): TokenCacheState,
State(settings): SettingsState,
Json(new_user): Json<NewUser>,
) -> Result<(), RouteError> {
ensure_local_auth_enabled(&settings)?;
new_user.validate()?;
db.add_user(
&new_user.name,
&new_user.pwd1,
new_user.is_admin,
new_user.is_read_only,
)
.await?;
cache.invalidate_all();
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::routing::{post, put};
use axum_extra::extract::cookie::Key;
use hyper::{Request, header};
use kellnr_appstate::{AppStateData, TEST_KEY, encode_cookies};
use kellnr_common::login_throttle::LoginThrottle;
use kellnr_common::token_cache::{CachedTokenData, TokenCacheManager};
use kellnr_db::AuthToken;
use kellnr_db::error::DbError;
use kellnr_db::mock::MockDb;
use kellnr_settings::constants::COOKIE_SESSION_ID;
use mockall::predicate::*;
use tower::ServiceExt;
use super::*;
fn test_state_with_cache(mock_db: MockDb, cache: Arc<TokenCacheManager>) -> AppStateData {
AppStateData {
db: Arc::new(mock_db),
signing_key: Key::from(TEST_KEY),
token_cache: cache,
..kellnr_appstate::test_state()
}
}
fn sso_enforced_state(mock_db: MockDb, cache: Arc<TokenCacheManager>) -> AppStateData {
let mut state = test_state_with_cache(mock_db, cache);
let mut settings = (*state.settings).clone();
settings.oauth2.enabled = true;
settings.oauth2.enforced = true;
state.settings = Arc::new(settings);
state
}
fn session_db(is_admin: bool) -> MockDb {
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().returning(move |_| {
Ok(kellnr_db::SessionInfo {
name: if is_admin { "admin" } else { "user" }.to_string(),
is_admin,
is_read_only: false,
})
});
mock_db
}
fn session_cookie_header() -> String {
encode_cookies([(COOKIE_SESSION_ID, "session")])
}
#[tokio::test]
async fn test_login_forbidden_when_sso_enforced() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = MockDb::new();
mock_db.expect_authenticate_user().never();
let state = sso_enforced_state(mock_db, cache);
let app = Router::new().route("/login", post(login)).with_state(state);
let response = app
.oneshot(
Request::post("/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"user":"admin","pwd":"admin"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_change_pwd_forbidden_when_sso_enforced() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = session_db(false);
mock_db.expect_authenticate_user().never();
mock_db.expect_change_pwd().never();
let state = sso_enforced_state(mock_db, cache);
let app = Router::new()
.route("/me/password", put(change_pwd))
.with_state(state);
let response = app
.oneshot(
Request::put("/me/password")
.header(header::COOKIE, session_cookie_header())
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"old_pwd":"old","new_pwd1":"newpassword","new_pwd2":"newpassword"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_reset_pwd_forbidden_when_sso_enforced() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = session_db(true);
mock_db.expect_change_pwd().never();
let state = sso_enforced_state(mock_db, cache);
let app = Router::new()
.route("/{name}/password", put(reset_pwd))
.with_state(state);
let response = app
.oneshot(
Request::put("/someuser/password")
.header(header::COOKIE, session_cookie_header())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_add_user_forbidden_when_sso_enforced() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = session_db(true);
mock_db.expect_add_user().never();
let state = sso_enforced_state(mock_db, cache);
let app = Router::new().route("/add", post(add)).with_state(state);
let response = app
.oneshot(
Request::post("/add")
.header(header::COOKIE, session_cookie_header())
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"name":"new_user","pwd1":"password","pwd2":"password","is_admin":false,"is_read_only":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
async fn post_login(app: &Router, user: &str) -> StatusCode {
app.clone()
.oneshot(
Request::post("/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(format!(r#"{{"user":"{user}","pwd":"guess"}}"#)))
.unwrap(),
)
.await
.unwrap()
.status()
}
#[tokio::test]
async fn test_login_throttled_after_failed_attempts() {
let mut mock_db = MockDb::new();
mock_db
.expect_authenticate_user()
.times(3)
.returning(|_, _| Err(DbError::PasswordMismatch));
let state = AppStateData {
login_throttle: Arc::new(LoginThrottle::new(2, std::time::Duration::from_mins(5))),
..test_state_with_cache(mock_db, Arc::new(TokenCacheManager::new(false, 60, 100)))
};
let app = Router::new().route("/login", post(login)).with_state(state);
assert_eq!(post_login(&app, "admin").await, StatusCode::UNAUTHORIZED);
assert_eq!(post_login(&app, "admin").await, StatusCode::UNAUTHORIZED);
assert_eq!(
post_login(&app, "admin").await,
StatusCode::TOO_MANY_REQUESTS
);
assert_eq!(post_login(&app, "other").await, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_change_pwd_throttled_after_failed_attempts() {
let mut mock_db = session_db(false);
mock_db
.expect_authenticate_user()
.times(1)
.returning(|_, _| Err(DbError::PasswordMismatch));
mock_db.expect_change_pwd().never();
let state = AppStateData {
login_throttle: Arc::new(LoginThrottle::new(1, std::time::Duration::from_mins(5))),
..test_state_with_cache(mock_db, Arc::new(TokenCacheManager::new(false, 60, 100)))
};
let app = Router::new()
.route("/me/password", put(change_pwd))
.with_state(state);
let mut statuses = Vec::new();
for _ in 0..2 {
let response = app
.clone()
.oneshot(
Request::put("/me/password")
.header(header::COOKIE, session_cookie_header())
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"old_pwd":"guess","new_pwd1":"newpassword","new_pwd2":"newpassword"}"#,
))
.unwrap(),
)
.await
.unwrap();
statuses.push(response.status());
}
assert_eq!(
statuses,
[StatusCode::BAD_REQUEST, StatusCode::TOO_MANY_REQUESTS]
);
}
#[tokio::test]
async fn test_logout_removes_session_cookie_with_root_path() {
let mut mock_db = MockDb::new();
mock_db
.expect_delete_session_token()
.with(eq("session"))
.times(1)
.returning(|_| Ok(()));
let state =
test_state_with_cache(mock_db, Arc::new(TokenCacheManager::new(false, 60, 100)));
let app = Router::new()
.route("/logout", post(logout))
.layer(Extension::<Option<Arc<kellnr_auth::oauth2::OAuth2Handler>>>(None))
.with_state(state);
let response = app
.oneshot(
Request::post("/logout")
.header(header::COOKIE, session_cookie_header())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let removal = response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.map(|v| v.to_str().unwrap().to_string())
.find(|c| c.starts_with(COOKIE_SESSION_ID))
.expect("session cookie must be removed");
assert!(removal.contains("Path=/"), "{removal}");
assert!(removal.contains("Max-Age=0"), "{removal}");
}
async fn insert_cached_credentials(cache: &TokenCacheManager) {
cache
.insert(
"basic:credentials".to_string(),
CachedTokenData {
user: "user".to_string(),
is_admin: false,
is_read_only: false,
},
cache.generation(),
)
.await;
}
#[tokio::test]
async fn test_change_pwd_invalidates_cache() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
insert_cached_credentials(&cache).await;
let mut mock_db = change_pwd_db();
mock_db
.expect_delete_other_user_sessions()
.returning(|_, _| Ok(0));
let status = put_change_pwd(test_state_with_cache(mock_db, cache.clone())).await;
assert_eq!(status, StatusCode::OK);
assert!(cache.get("basic:credentials").await.is_none());
}
#[tokio::test]
async fn test_reset_pwd_invalidates_cache() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
insert_cached_credentials(&cache).await;
let mut mock_db = reset_pwd_db();
mock_db.expect_delete_user_sessions().returning(|_| Ok(0));
let status = put_reset_pwd(test_state_with_cache(mock_db, cache.clone()), "user").await;
assert_eq!(status, StatusCode::OK);
assert!(cache.get("basic:credentials").await.is_none());
}
fn change_pwd_db() -> MockDb {
let mut mock_db = session_db(false);
mock_db.expect_authenticate_user().returning(|name, _| {
Ok(User {
name: name.to_string(),
..User::default()
})
});
mock_db
.expect_change_pwd()
.times(1)
.returning(|_, _| Ok(()));
mock_db
}
fn reset_pwd_db() -> MockDb {
let mut mock_db = session_db(true);
mock_db
.expect_change_pwd()
.times(1)
.returning(|_, _| Ok(()));
mock_db
}
async fn put_change_pwd(state: AppStateData) -> StatusCode {
Router::new()
.route("/me/password", put(change_pwd))
.with_state(state)
.oneshot(
Request::put("/me/password")
.header(header::COOKIE, session_cookie_header())
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"old_pwd":"old","new_pwd1":"newpassword","new_pwd2":"newpassword"}"#,
))
.unwrap(),
)
.await
.unwrap()
.status()
}
async fn put_reset_pwd(state: AppStateData, name: &str) -> StatusCode {
Router::new()
.route("/{name}/password", put(reset_pwd))
.with_state(state)
.oneshot(
Request::put(format!("/{name}/password"))
.header(header::COOKIE, session_cookie_header())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
.status()
}
#[tokio::test]
async fn test_change_pwd_revokes_other_sessions() {
let mut mock_db = change_pwd_db();
mock_db
.expect_delete_other_user_sessions()
.with(eq("user"), eq("session"))
.times(1)
.returning(|_, _| Ok(2));
mock_db.expect_delete_user_sessions().never();
let status = put_change_pwd(test_state_with_cache(
mock_db,
Arc::new(TokenCacheManager::new(false, 60, 100)),
))
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn test_reset_pwd_revokes_all_sessions_of_user() {
let mut mock_db = reset_pwd_db();
mock_db
.expect_delete_user_sessions()
.with(eq("user"))
.times(1)
.returning(|_| Ok(1));
mock_db.expect_delete_other_user_sessions().never();
let status = put_reset_pwd(
test_state_with_cache(mock_db, Arc::new(TokenCacheManager::new(false, 60, 100))),
"user",
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn test_reset_own_pwd_keeps_current_session() {
let mut mock_db = reset_pwd_db();
mock_db
.expect_delete_other_user_sessions()
.with(eq("admin"), eq("session"))
.times(1)
.returning(|_, _| Ok(0));
mock_db.expect_delete_user_sessions().never();
let status = put_reset_pwd(
test_state_with_cache(mock_db, Arc::new(TokenCacheManager::new(false, 60, 100))),
"admin",
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn test_add_token_invalidates_cache() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
cache
.insert(
"existing_token".to_string(),
CachedTokenData {
user: "test_user".to_string(),
is_admin: false,
is_read_only: false,
},
cache.generation(),
)
.await;
assert!(cache.get("existing_token").await.is_some());
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "test_user".to_string(),
is_admin: false,
is_read_only: false,
})
});
mock_db
.expect_add_auth_token()
.times(1)
.returning(|_, _, _| Ok(()));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/add_token", post(add_token))
.with_state(state);
let response = app
.oneshot(
Request::post("/add_token")
.header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"name":"new_token"}"#))
.unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_success(),
"Expected success but got {}",
response.status()
);
assert!(cache.get("existing_token").await.is_none());
}
#[tokio::test]
async fn test_delete_token_invalidates_cache() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
cache
.insert(
"token_to_keep".to_string(),
CachedTokenData {
user: "test_user".to_string(),
is_admin: false,
is_read_only: false,
},
cache.generation(),
)
.await;
assert!(cache.get("token_to_keep").await.is_some());
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "test_user".to_string(),
is_admin: false,
is_read_only: false,
})
});
mock_db.expect_get_auth_tokens().times(1).returning(|_| {
Ok(vec![AuthToken::new(
1,
"token".to_string(),
"secret".to_string(),
)])
});
mock_db
.expect_delete_auth_token()
.times(1)
.with(eq(1))
.returning(|_| Ok(()));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/delete_token/{id}", axum::routing::delete(delete_token))
.with_state(state);
let response = app
.oneshot(
Request::delete("/delete_token/1")
.header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_success(),
"Expected success but got {}",
response.status()
);
assert!(cache.get("token_to_keep").await.is_none());
}
#[tokio::test]
async fn test_delete_user_invalidates_cache() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
cache
.insert(
"user_token".to_string(),
CachedTokenData {
user: "user_to_delete".to_string(),
is_admin: false,
is_read_only: false,
},
cache.generation(),
)
.await;
assert!(cache.get("user_token").await.is_some());
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
}); mock_db
.expect_delete_user()
.times(1)
.with(eq("user_to_delete"))
.returning(|_| Ok(()));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/delete/{name}", axum::routing::delete(delete))
.with_state(state);
let response = app
.oneshot(
Request::delete("/delete/user_to_delete")
.header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_success(),
"Expected success but got {}",
response.status()
);
assert!(cache.get("user_token").await.is_none());
}
#[tokio::test]
async fn test_read_only_change_invalidates_cache() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
cache
.insert(
"user_token".to_string(),
CachedTokenData {
user: "target_user".to_string(),
is_admin: false,
is_read_only: false, },
cache.generation(),
)
.await;
assert!(cache.get("user_token").await.is_some());
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
}); mock_db
.expect_change_read_only_state()
.times(1)
.with(eq("target_user"), eq(true))
.returning(|_, _| Ok(()));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/read_only/{name}", post(read_only))
.with_state(state);
let response = app
.oneshot(
Request::post("/read_only/target_user")
.header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"state":true}"#))
.unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_success(),
"Expected success but got {}",
response.status()
);
assert!(cache.get("user_token").await.is_none());
}
#[tokio::test]
async fn test_admin_self_locking_prevented() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
});
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/read_only/{name}", post(read_only))
.with_state(state);
let response = app
.oneshot(
Request::post("/read_only/admin") .header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"state":true}"#)) .unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"Expected BAD_REQUEST but got {}",
response.status()
);
}
#[tokio::test]
async fn test_admin_self_unlocking_allowed() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
}); mock_db
.expect_change_read_only_state()
.times(1)
.with(eq("admin"), eq(false)) .returning(|_, _| Ok(()));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/read_only/{name}", post(read_only))
.with_state(state);
let response = app
.oneshot(
Request::post("/read_only/admin") .header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"state":false}"#)) .unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_success(),
"Expected success but got {}",
response.status()
);
}
#[tokio::test]
async fn test_admin_locking_other_user_works() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
}); mock_db
.expect_change_read_only_state()
.times(1)
.with(eq("other_user"), eq(true))
.returning(|_, _| Ok(()));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/read_only/{name}", post(read_only))
.with_state(state);
let response = app
.oneshot(
Request::post("/read_only/other_user") .header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"state":true}"#))
.unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_success(),
"Expected success but got {}",
response.status()
);
}
#[tokio::test]
async fn test_add_user_invalidates_cache() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
cache
.insert(
"existing_token".to_string(),
CachedTokenData {
user: "existing_user".to_string(),
is_admin: false,
is_read_only: false,
},
cache.generation(),
)
.await;
assert!(cache.get("existing_token").await.is_some());
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
}); mock_db
.expect_add_user()
.times(1)
.returning(|_, _, _, _| Ok(()));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new().route("/add", post(add)).with_state(state);
let response = app
.oneshot(
Request::post("/add")
.header(header::COOKIE, encode_cookies([(COOKIE_SESSION_ID, "session")]))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"name":"new_user","pwd1":"password","pwd2":"password","is_admin":false,"is_read_only":false}"#))
.unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_success(),
"Expected success but got {}",
response.status()
);
assert!(cache.get("existing_token").await.is_none());
}
#[tokio::test]
async fn test_cache_not_invalidated_on_db_failure() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
cache
.insert(
"existing_token".to_string(),
CachedTokenData {
user: "test_user".to_string(),
is_admin: false,
is_read_only: false,
},
cache.generation(),
)
.await;
assert!(cache.get("existing_token").await.is_some());
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "test_user".to_string(),
is_admin: false,
is_read_only: false,
})
});
mock_db
.expect_add_auth_token()
.times(1)
.returning(|_, _, _| {
Err(DbError::InitializationError(
"Connection timeout".to_string(),
))
});
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/add_token", post(add_token))
.with_state(state);
let response = app
.oneshot(
Request::post("/add_token")
.header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"name":"new_token"}"#))
.unwrap(),
)
.await
.unwrap();
assert!(!response.status().is_success());
assert!(cache.get("existing_token").await.is_some());
}
#[tokio::test]
async fn test_admin_change_invalidates_cache() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
cache
.insert(
"user_token".to_string(),
CachedTokenData {
user: "target_user".to_string(),
is_admin: false, is_read_only: false,
},
cache.generation(),
)
.await;
assert!(cache.get("user_token").await.is_some());
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
}); mock_db
.expect_change_admin_state()
.times(1)
.with(eq("target_user"), eq(true))
.returning(|_, _| Ok(()));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/admin/{name}", post(admin))
.with_state(state);
let response = app
.oneshot(
Request::post("/admin/target_user")
.header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"state":true}"#))
.unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_success(),
"Expected success but got {}",
response.status()
);
assert!(cache.get("user_token").await.is_none());
}
#[tokio::test]
async fn test_admin_self_demotion_prevented() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
});
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/admin/{name}", post(admin))
.with_state(state);
let response = app
.oneshot(
Request::post("/admin/admin") .header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"state":false}"#)) .unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"Expected BAD_REQUEST but got {}",
response.status()
);
}
#[tokio::test]
async fn test_non_admin_cannot_change_admin_status() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "regular_user".to_string(),
is_admin: false,
is_read_only: false,
})
});
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/admin/{name}", post(admin))
.with_state(state);
let response = app
.oneshot(
Request::post("/admin/target_user")
.header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"state":true}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"Expected FORBIDDEN but got {}",
response.status()
);
}
#[tokio::test]
async fn test_admin_demotion_works() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
}); mock_db
.expect_change_admin_state()
.times(1)
.with(eq("other_admin"), eq(false)) .returning(|_, _| Ok(()));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/admin/{name}", post(admin))
.with_state(state);
let response = app
.oneshot(
Request::post("/admin/other_admin")
.header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"state":false}"#)) .unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_success(),
"Expected success but got {}",
response.status()
);
}
#[tokio::test]
async fn test_admin_nonexistent_user_returns_error() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
}); mock_db
.expect_change_admin_state()
.times(1)
.with(eq("nonexistent"), eq(true))
.returning(|_, _| Err(DbError::UserNotFound("nonexistent".to_string())));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/admin/{name}", post(admin))
.with_state(state);
let response = app
.oneshot(
Request::post("/admin/nonexistent")
.header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"state":true}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"Expected NOT_FOUND but got {}",
response.status()
);
}
#[tokio::test]
async fn test_admin_self_promotion_allowed() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
}); mock_db
.expect_change_admin_state()
.times(1)
.with(eq("admin"), eq(true)) .returning(|_, _| Ok(()));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/admin/{name}", post(admin))
.with_state(state);
let response = app
.oneshot(
Request::post("/admin/admin") .header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"state":true}"#)) .unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_success(),
"Expected success but got {}",
response.status()
);
}
#[tokio::test]
async fn test_admin_self_deletion_prevented() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
});
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/delete/{name}", axum::routing::delete(delete))
.with_state(state);
let response = app
.oneshot(
Request::delete("/delete/admin") .header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"Expected BAD_REQUEST but got {}",
response.status()
);
}
#[tokio::test]
async fn test_admin_deletion_of_other_user_works() {
let cache = Arc::new(TokenCacheManager::new(true, 60, 100));
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().times(1).returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
}); mock_db
.expect_delete_user()
.times(1)
.with(eq("other_user"))
.returning(|_| Ok(()));
let state = test_state_with_cache(mock_db, cache.clone());
let app = Router::new()
.route("/delete/{name}", axum::routing::delete(delete))
.with_state(state);
let response = app
.oneshot(
Request::delete("/delete/other_user") .header(
header::COOKIE,
encode_cookies([(COOKIE_SESSION_ID, "session")]),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
response.status().is_success(),
"Expected success but got {}",
response.status()
);
}
}