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",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tonic",
|
"tonic",
|
||||||
"tower",
|
"tower",
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ prost-types = "0.14"
|
|||||||
tonic-prost = "0.14"
|
tonic-prost = "0.14"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
|
time = "0.3"
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ tower.workspace = true
|
|||||||
tower-http.workspace = true
|
tower-http.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
time.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|||||||
@@ -139,15 +139,19 @@ impl FromRequestParts<AppState> for MaybeSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a Set-Cookie header for the session.
|
/// Build a Set-Cookie header for the session.
|
||||||
pub fn session_cookie(session_id: &SessionId) -> CookieJar {
|
/// When `remember` is true, the cookie persists for 30 days; otherwise it is a session cookie.
|
||||||
let cookie = Cookie::build((SESSION_COOKIE, session_id.to_string()))
|
pub fn session_cookie(session_id: &SessionId, remember: bool) -> CookieJar {
|
||||||
|
let mut builder = Cookie::build((SESSION_COOKIE, session_id.to_string()))
|
||||||
.path("/")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.secure(true)
|
.secure(true)
|
||||||
.same_site(axum_extra::extract::cookie::SameSite::Lax)
|
.same_site(axum_extra::extract::cookie::SameSite::Lax);
|
||||||
.build();
|
|
||||||
|
|
||||||
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.
|
/// 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 {
|
match state.sessions.create(session_data).await {
|
||||||
Ok(session_id) => {
|
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())
|
Ok((cookie, Redirect::to("/dashboard")).into_response())
|
||||||
}
|
}
|
||||||
Err(_) => render_signup(
|
Err(_) => render_signup(
|
||||||
@@ -208,6 +208,8 @@ async fn login_page(
|
|||||||
struct LoginForm {
|
struct LoginForm {
|
||||||
identifier: String,
|
identifier: String,
|
||||||
password: String,
|
password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
remember_me: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn login_submit(
|
async fn login_submit(
|
||||||
@@ -273,9 +275,10 @@ async fn login_submit(
|
|||||||
last_seen_at: now,
|
last_seen_at: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let remember = form.remember_me.is_some();
|
||||||
match state.sessions.create(session_data).await {
|
match state.sessions.create(session_data).await {
|
||||||
Ok(session_id) => {
|
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())
|
Ok((cookie, Redirect::to("/dashboard")).into_response())
|
||||||
}
|
}
|
||||||
Err(_) => render_login(
|
Err(_) => render_login(
|
||||||
|
|||||||
@@ -386,6 +386,58 @@ async fn expired_session_with_failed_refresh_redirects_to_login() {
|
|||||||
assert_eq!(sessions.session_count(), 0);
|
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 ─────────────────────────────────────────────────────────
|
// ─── Logout ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
placeholder="Your password">
|
placeholder="Your password">
|
||||||
</div>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="w-full py-2 bg-gray-900 text-white rounded-md font-medium hover:bg-gray-800">
|
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