feat: implement session expiry extension and 401 recovery

This commit is contained in:
2026-01-30 16:01:17 -06:00
parent 669dec0235
commit fb27bdc119
11 changed files with 93 additions and 32 deletions
+17 -7
View File
@@ -7,6 +7,9 @@ use sqlx::PgPool;
use super::models::UserSession; use super::models::UserSession;
use crate::error::Result; use crate::error::Result;
/// Session lifetime: 7 days (in seconds).
pub const SESSION_DURATION_SECS: u64 = 7 * 24 * 3600;
/// Generate a cryptographically random 32-byte hex token. /// Generate a cryptographically random 32-byte hex token.
fn generate_token() -> String { fn generate_token() -> String {
let bytes: [u8; 32] = rand::rng().random(); let bytes: [u8; 32] = rand::rng().random();
@@ -48,13 +51,21 @@ pub async fn get_session(pool: &PgPool, token: &str) -> Result<Option<UserSessio
.context("failed to get session") .context("failed to get session")
} }
/// Update the last-active timestamp for a session. /// Update the last-active timestamp and extend session expiry (sliding window).
pub async fn touch_session(pool: &PgPool, token: &str) -> Result<()> { pub async fn touch_session(pool: &PgPool, token: &str) -> Result<()> {
sqlx::query("UPDATE user_sessions SET last_active_at = now() WHERE id = $1") sqlx::query(
.bind(token) r#"
.execute(pool) UPDATE user_sessions
.await SET last_active_at = now(),
.context("failed to touch session")?; expires_at = now() + make_interval(secs => $2::double precision)
WHERE id = $1
"#,
)
.bind(token)
.bind(SESSION_DURATION_SECS as f64)
.execute(pool)
.await
.context("failed to touch session")?;
Ok(()) Ok(())
} }
@@ -80,7 +91,6 @@ pub async fn delete_user_sessions(pool: &PgPool, user_id: i64) -> Result<u64> {
} }
/// Delete all expired sessions. Returns the number of sessions cleaned up. /// Delete all expired sessions. Returns the number of sessions cleaned up.
#[allow(dead_code)] // Called by SessionCache::cleanup_expired (not yet wired to periodic task)
pub async fn cleanup_expired(pool: &PgPool) -> Result<u64> { pub async fn cleanup_expired(pool: &PgPool) -> Result<u64> {
let result = sqlx::query("DELETE FROM user_sessions WHERE expires_at <= now()") let result = sqlx::query("DELETE FROM user_sessions WHERE expires_at <= now()")
.execute(pool) .execute(pool)
+34
View File
@@ -51,6 +51,33 @@ impl WebService {
} }
} }
} }
/// Periodically cleans up expired sessions from the database and in-memory cache.
async fn session_cleanup_loop(state: AppState, mut shutdown_rx: broadcast::Receiver<()>) {
use std::time::Duration;
// Run every hour
let mut interval = tokio::time::interval(Duration::from_secs(3600));
loop {
tokio::select! {
_ = interval.tick() => {
match state.session_cache.cleanup_expired().await {
Ok(deleted) => {
if deleted > 0 {
info!(deleted, "cleaned up expired sessions");
}
}
Err(e) => {
warn!(error = %e, "session cleanup failed");
}
}
}
_ = shutdown_rx.recv() => {
break;
}
}
}
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -87,6 +114,13 @@ impl Service for WebService {
Self::db_health_check_loop(health_state, health_shutdown_rx).await; Self::db_health_check_loop(health_state, health_shutdown_rx).await;
}); });
// Spawn session cleanup task
let cleanup_state = self.app_state.clone();
let cleanup_shutdown_rx = shutdown_tx.subscribe();
tokio::spawn(async move {
Self::session_cleanup_loop(cleanup_state, cleanup_shutdown_rx).await;
});
// Use axum's graceful shutdown with the internal shutdown signal // Use axum's graceful shutdown with the internal shutdown signal
axum::serve(listener, app) axum::serve(listener, app)
.with_graceful_shutdown(async move { .with_graceful_shutdown(async move {
+6 -2
View File
@@ -235,7 +235,7 @@ pub async fn auth_callback(
let session = crate::data::sessions::create_session( let session = crate::data::sessions::create_session(
&state.db_pool, &state.db_pool,
discord_id, discord_id,
Duration::from_secs(7 * 24 * 3600), Duration::from_secs(crate::data::sessions::SESSION_DURATION_SECS),
) )
.await .await
.map_err(|e| { .map_err(|e| {
@@ -248,7 +248,11 @@ pub async fn auth_callback(
// 6. Build response with session cookie // 6. Build response with session cookie
let secure = redirect_uri.starts_with("https://"); let secure = redirect_uri.starts_with("https://");
let cookie = session_cookie(&session.id, 604800, secure); let cookie = session_cookie(
&session.id,
crate::data::sessions::SESSION_DURATION_SECS as i64,
secure,
);
let redirect_to = if user.is_admin { "/admin" } else { "/" }; let redirect_to = if user.is_admin { "/admin" } else { "/" };
+5 -6
View File
@@ -9,8 +9,8 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use serde_json::Value; use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{debug, error, info}; use tracing::{debug, error, info};
@@ -141,11 +141,10 @@ struct ScheduleRow {
async fn load_snapshot(pool: &PgPool) -> anyhow::Result<ScheduleSnapshot> { async fn load_snapshot(pool: &PgPool) -> anyhow::Result<ScheduleSnapshot> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let rows: Vec<ScheduleRow> = sqlx::query_as( let rows: Vec<ScheduleRow> =
"SELECT subject, enrollment, meeting_times FROM courses", sqlx::query_as("SELECT subject, enrollment, meeting_times FROM courses")
) .fetch_all(pool)
.fetch_all(pool) .await?;
.await?;
let courses: Vec<CachedCourse> = rows let courses: Vec<CachedCourse> = rows
.into_iter() .into_iter()
-1
View File
@@ -108,7 +108,6 @@ impl SessionCache {
/// Delete expired sessions from the database and sweep the in-memory cache. /// Delete expired sessions from the database and sweep the in-memory cache.
/// ///
/// Returns the number of sessions deleted from the database. /// Returns the number of sessions deleted from the database.
#[allow(dead_code)] // Intended for periodic cleanup task (not yet wired)
pub async fn cleanup_expired(&self) -> anyhow::Result<u64> { pub async fn cleanup_expired(&self) -> anyhow::Result<u64> {
let deleted = crate::data::sessions::cleanup_expired(&self.db_pool).await?; let deleted = crate::data::sessions::cleanup_expired(&self.db_pool).await?;
+2 -3
View File
@@ -233,9 +233,8 @@ pub(crate) async fn timeline(
s.active_during(local_date, wday_bit, slot_start_minutes, slot_end_minutes) s.active_during(local_date, wday_bit, slot_start_minutes, slot_end_minutes)
}); });
if active { if active {
*subject_totals *subject_totals.entry(course.subject.clone()).or_default() +=
.entry(course.subject.clone()) course.enrollment as i64;
.or_default() += course.enrollment as i64;
} }
} }
+9
View File
@@ -1,3 +1,4 @@
import { authStore } from "$lib/auth.svelte";
import type { import type {
CandidateResponse, CandidateResponse,
CodeDescription, CodeDescription,
@@ -212,6 +213,10 @@ export class BannerApiClient {
const response = await this.fetchFn(...args); const response = await this.fetchFn(...args);
if (response.status === 401) {
authStore.handleUnauthorized();
}
if (!response.ok) { if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`); throw new Error(`API request failed: ${response.status} ${response.statusText}`);
} }
@@ -229,6 +234,10 @@ export class BannerApiClient {
const response = await this.fetchFn(...args); const response = await this.fetchFn(...args);
if (response.status === 401) {
authStore.handleUnauthorized();
}
if (!response.ok) { if (!response.ok) {
throw new Error(`API request failed: ${response.status} ${response.statusText}`); throw new Error(`API request failed: ${response.status} ${response.statusText}`);
} }
+7
View File
@@ -60,6 +60,13 @@ class AuthStore {
} }
} }
/** Idempotently mark the session as lost. Called by apiFetch on 401. */
handleUnauthorized() {
if (this.state.mode !== "unauthenticated") {
this.state = { mode: "unauthenticated" };
}
}
login() { login() {
window.location.href = "/api/auth/login"; window.location.href = "/api/auth/login";
} }
+10 -6
View File
@@ -11,11 +11,15 @@ const staticTabs = [
const APP_PREFIXES = ["/profile", "/settings", "/admin"]; const APP_PREFIXES = ["/profile", "/settings", "/admin"];
let profileTab = $derived({ let profileTab = $derived(
href: authStore.isAuthenticated ? "/profile" : "/login", authStore.isLoading
label: authStore.isAuthenticated ? "Account" : "Login", ? { href: "/login" as const, label: null, icon: User }
icon: User, : {
}); href: authStore.isAuthenticated ? ("/profile" as const) : ("/login" as const),
label: authStore.isAuthenticated ? "Account" : "Login",
icon: User,
}
);
function isActive(tabHref: string): boolean { function isActive(tabHref: string): boolean {
if (tabHref === "/") return page.url.pathname === "/"; if (tabHref === "/") return page.url.pathname === "/";
@@ -50,7 +54,7 @@ function isActive(tabHref: string): boolean {
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}" : 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
> >
<User size={15} strokeWidth={2} /> <User size={15} strokeWidth={2} />
{profileTab.label} {#if profileTab.label}{profileTab.label}{/if}
</a> </a>
<ThemeToggle /> <ThemeToggle />
</div> </div>
+1 -7
View File
@@ -15,7 +15,7 @@ import {
User, User,
Users, Users,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { onMount, tick } from "svelte"; import { tick } from "svelte";
let { children } = $props(); let { children } = $props();
@@ -41,12 +41,6 @@ $effect(() => {
} }
}); });
onMount(async () => {
if (authStore.isLoading) {
await authStore.init();
}
});
$effect(() => { $effect(() => {
if (authStore.state.mode === "unauthenticated") { if (authStore.state.mode === "unauthenticated") {
goto("/login"); goto("/login");
+2
View File
@@ -7,6 +7,7 @@ import NavBar from "$lib/components/NavBar.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte"; import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import { initNavigation } from "$lib/stores/navigation.svelte"; import { initNavigation } from "$lib/stores/navigation.svelte";
import { themeStore } from "$lib/stores/theme.svelte"; import { themeStore } from "$lib/stores/theme.svelte";
import { authStore } from "$lib/auth.svelte";
import { Tooltip } from "bits-ui"; import { Tooltip } from "bits-ui";
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte"; import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
@@ -34,6 +35,7 @@ useOverlayScrollbars(() => document.body, {
onMount(() => { onMount(() => {
themeStore.init(); themeStore.init();
authStore.init();
}); });
</script> </script>