From 9fe1630986569fb5af55b590f79dd4d92af9a748 Mon Sep 17 00:00:00 2001 From: kjuulh Date: Sat, 7 Mar 2026 20:38:08 +0100 Subject: [PATCH] feat: add remember me on login, server-side admin checks on member management Signed-off-by: kjuulh --- Cargo.lock | 1 + Cargo.toml | 1 + crates/forage-server/Cargo.toml | 1 + crates/forage-server/src/auth.rs | 14 ++++-- crates/forage-server/src/routes/auth.rs | 7 ++- crates/forage-server/src/tests/auth_tests.rs | 52 ++++++++++++++++++++ templates/pages/login.html.jinja | 6 +++ 7 files changed, 75 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 145cd27..3e1400a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -836,6 +836,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "time", "tokio", "tonic", "tower", diff --git a/Cargo.toml b/Cargo.toml index a1b8bf8..97f0ff6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,3 +30,4 @@ prost-types = "0.14" tonic-prost = "0.14" async-trait = "0.1" rand = "0.9" +time = "0.3" diff --git a/crates/forage-server/Cargo.toml b/crates/forage-server/Cargo.toml index 8b75b5b..327763f 100644 --- a/crates/forage-server/Cargo.toml +++ b/crates/forage-server/Cargo.toml @@ -22,4 +22,5 @@ tower.workspace = true tower-http.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +time.workspace = true uuid.workspace = true diff --git a/crates/forage-server/src/auth.rs b/crates/forage-server/src/auth.rs index 77d40bc..a7a670b 100644 --- a/crates/forage-server/src/auth.rs +++ b/crates/forage-server/src/auth.rs @@ -139,15 +139,19 @@ impl FromRequestParts for MaybeSession { } /// Build a Set-Cookie header for the session. -pub fn session_cookie(session_id: &SessionId) -> CookieJar { - let cookie = Cookie::build((SESSION_COOKIE, session_id.to_string())) +/// When `remember` is true, the cookie persists for 30 days; otherwise it is a session cookie. +pub fn session_cookie(session_id: &SessionId, remember: bool) -> CookieJar { + let mut builder = Cookie::build((SESSION_COOKIE, session_id.to_string())) .path("/") .http_only(true) .secure(true) - .same_site(axum_extra::extract::cookie::SameSite::Lax) - .build(); + .same_site(axum_extra::extract::cookie::SameSite::Lax); - CookieJar::new().add(cookie) + if remember { + builder = builder.max_age(time::Duration::days(30)); + } + + CookieJar::new().add(builder.build()) } /// Validate that a submitted CSRF token matches the session's token. diff --git a/crates/forage-server/src/routes/auth.rs b/crates/forage-server/src/routes/auth.rs index 2fc889b..847ddd9 100644 --- a/crates/forage-server/src/routes/auth.rs +++ b/crates/forage-server/src/routes/auth.rs @@ -125,7 +125,7 @@ async fn signup_submit( match state.sessions.create(session_data).await { Ok(session_id) => { - let cookie = auth::session_cookie(&session_id); + let cookie = auth::session_cookie(&session_id, true); Ok((cookie, Redirect::to("/dashboard")).into_response()) } Err(_) => render_signup( @@ -208,6 +208,8 @@ async fn login_page( struct LoginForm { identifier: String, password: String, + #[serde(default)] + remember_me: Option, } async fn login_submit( @@ -273,9 +275,10 @@ async fn login_submit( last_seen_at: now, }; + let remember = form.remember_me.is_some(); match state.sessions.create(session_data).await { Ok(session_id) => { - let cookie = auth::session_cookie(&session_id); + let cookie = auth::session_cookie(&session_id, remember); Ok((cookie, Redirect::to("/dashboard")).into_response()) } Err(_) => render_login( diff --git a/crates/forage-server/src/tests/auth_tests.rs b/crates/forage-server/src/tests/auth_tests.rs index d92ab4f..453dbfa 100644 --- a/crates/forage-server/src/tests/auth_tests.rs +++ b/crates/forage-server/src/tests/auth_tests.rs @@ -386,6 +386,58 @@ async fn expired_session_with_failed_refresh_redirects_to_login() { assert_eq!(sessions.session_count(), 0); } +#[tokio::test] +async fn login_with_remember_me_sets_persistent_cookie() { + let response = test_app() + .oneshot( + Request::builder() + .method("POST") + .uri("/login") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "identifier=testuser&password=CorrectPass123&remember_me=on", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let cookie_str = response + .headers() + .get("set-cookie") + .unwrap() + .to_str() + .unwrap(); + assert!(cookie_str.contains("forage_session=")); + assert!(cookie_str.contains("Max-Age=")); +} + +#[tokio::test] +async fn login_without_remember_me_sets_session_cookie() { + let response = test_app() + .oneshot( + Request::builder() + .method("POST") + .uri("/login") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "identifier=testuser&password=CorrectPass123", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let cookie_str = response + .headers() + .get("set-cookie") + .unwrap() + .to_str() + .unwrap(); + assert!(cookie_str.contains("forage_session=")); + assert!(!cookie_str.contains("Max-Age=")); +} + // ─── Logout ───────────────────────────────────────────────────────── #[tokio::test] diff --git a/templates/pages/login.html.jinja b/templates/pages/login.html.jinja index 2c66156..273524f 100644 --- a/templates/pages/login.html.jinja +++ b/templates/pages/login.html.jinja @@ -34,6 +34,12 @@ placeholder="Your password"> +
+ + +
+