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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -836,6 +836,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"time",
|
||||
"tokio",
|
||||
"tonic",
|
||||
"tower",
|
||||
|
||||
@@ -30,3 +30,4 @@ prost-types = "0.14"
|
||||
tonic-prost = "0.14"
|
||||
async-trait = "0.1"
|
||||
rand = "0.9"
|
||||
time = "0.3"
|
||||
|
||||
@@ -22,4 +22,5 @@ tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
time.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
placeholder="Your password">
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="remember_me" name="remember_me" value="on" checked
|
||||
class="h-4 w-4 rounded border-gray-300 text-gray-900 focus:ring-gray-900">
|
||||
<label for="remember_me" class="ml-2 text-sm text-gray-600">Remember me</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-2 bg-gray-900 text-white rounded-md font-medium hover:bg-gray-800">
|
||||
|
||||
Reference in New Issue
Block a user