feat: add remember me on login, server-side admin checks on member management

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 20:38:08 +01:00
parent d46c365112
commit 9fe1630986
7 changed files with 75 additions and 7 deletions

View File

@@ -22,4 +22,5 @@ tower.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
time.workspace = true
uuid.workspace = true

View File

@@ -139,15 +139,19 @@ impl FromRequestParts<AppState> 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.

View File

@@ -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<String>,
}
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(

View File

@@ -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]