Repository navigation
feat(session): move the SPA session poll off the legacy CFM #306
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,82 +2,81 @@ | |
| import { ref } from "vue" | ||
| import { useUserStore } from "@/store/UserStore" | ||
| import LoginButton from "@/components/LoginButton.vue" | ||
| import StatusBanner from "@/components/StatusBanner.vue" | ||
|
|
||
| const userStore = useUserStore() | ||
| //https://" + HttpHelper.HttpContext?.Request.Host.Value | ||
| const onDev = import.meta.env.VITE_ENVIRONMENT === "DEVELOPMENT" | ||
| const viperHome = import.meta.env.VITE_VIPER_HOME | ||
| const sessionRefreshUrl = | ||
| (onDev ? "http://localhost/" : "/") + | ||
| "public/timeout/seconds_until_timeout_v2.cfm?id=" + | ||
| userStore.userInfo.loginId + | ||
| "&service=" + | ||
| (onDev ? "Viper2-dev" : "Viper2") | ||
| const sessionTimeoutUrl = `${import.meta.env.VITE_API_URL}sessionTimeout` | ||
| const showSessionTimeoutWarning = ref(false) | ||
| const sessionExpireTime = ref("") | ||
| const sessionExpired = ref(false) | ||
| let sessionTimeoutCheckEventId = 0 | ||
| const sessionReloaded = ref(false) | ||
| const sessionExtendFailed = ref(false) | ||
|
|
||
| function formatExpireTime(sessionTimeoutDateTime: string) { | ||
| return new Date(sessionTimeoutDateTime).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) | ||
| } | ||
|
|
||
| // Plain fetch, not useFetch: that reports every failure to the global error store and fires the | ||
| // auth handler, which would spam a silent poll and fight our own dialog's Log in. | ||
| async function checkSessionTimeout() { | ||
| if ( | ||
| userStore.userInfo.loginId === undefined || | ||
| userStore.userInfo.loginId === null || | ||
| userStore.userInfo.loginId.length === 0 | ||
| ) { | ||
| // One live timer only: a refresh that overlaps an in-flight poll must not leave two chains running. | ||
| clearTimeout(sessionTimeoutCheckEventId) | ||
| if (!userStore.userInfo.loginId) { | ||
| return //don't check the session if the user is not logged in | ||
| } | ||
| //try to get the session timeout from an external application | ||
| try { | ||
| fetch(sessionRefreshUrl) | ||
| .then((r) => (r.status === 200 ? r.json() : r)) | ||
| .then((r) => { | ||
| let nextCheck = 300 | ||
| //show timeout warning if the session will time out in 5 minutes or less | ||
| if (r.secondsUntilTimeout !== undefined && r.secondsUntilTimeout < 300) { | ||
| showSessionTimeoutWarning.value = true | ||
| sessionExpired.value = r.secondsUntilTimeout < 15 //consider session timing out in 15 seconds to be timed out already | ||
| var d = new Date(r.sessionTimeoutDateTime) | ||
| sessionExpireTime.value = | ||
| (d.getHours() > 12 ? d.getHours() - 12 : d.getHours()) + | ||
| ":" + | ||
| ("0" + d.getMinutes()).slice(-2) + | ||
| (d.getHours() >= 12 ? " PM" : " AM") | ||
| nextCheck = sessionExpired.value ? 0 : Math.max(r.secondsUntilTimeout - 15, 5) | ||
| } | ||
| if (nextCheck > 0) { | ||
| sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, nextCheck * 1000) | ||
| } | ||
| }) | ||
| } catch (e) { | ||
| void e | ||
| } | ||
| } | ||
| async function extendSession() { | ||
| fetch(viperHome + "RefreshSession") | ||
| .then((r) => (r.status === 200 ? r.json() : r)) | ||
| // Timeout so a request that hangs rather than fails still reaches the catch below. | ||
| fetch(sessionTimeoutUrl, { signal: AbortSignal.timeout(10000) }) | ||
|
rlorenzo marked this conversation as resolved.
|
||
| .then((r) => (r.ok ? r.json() : Promise.reject(new Error("Session check returned " + r.status)))) | ||
|
rlorenzo marked this conversation as resolved.
|
||
| .then((r) => { | ||
| try { | ||
| clearTimeout(sessionTimeoutCheckEventId) | ||
| } catch (e) { | ||
| void e | ||
| let nextCheck = 300 | ||
| //show timeout warning if the session will time out in 5 minutes or less | ||
| // "<=" so an exact 300 still warns: otherwise the next poll lands at expiry. | ||
| if (r.secondsUntilTimeout !== undefined && r.secondsUntilTimeout <= 300) { | ||
| showSessionTimeoutWarning.value = true | ||
| sessionExpired.value = r.secondsUntilTimeout < 15 //consider session timing out in 15 seconds to be timed out already | ||
| sessionExpireTime.value = formatExpireTime(r.sessionTimeoutDateTime) | ||
| nextCheck = sessionExpired.value ? 0 : Math.max(r.secondsUntilTimeout - 15, 5) | ||
|
Comment on lines
+38
to
+40
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: nl -ba VueApp/src/components/SessionTimeout.vue | sed -n '16,98p'
nl -ba web/Controllers/SessionTimeoutController.cs | sed -n '18,95p'
git show 24d17e137426fd8b608f59e7de21f02628442fa9:VueApp/src/components/SessionTimeout.vue | sed -n '1,115p'Repository: ucdavis/VIPER Length of output: 11619 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- status service and model definitions ---'
rg -n -C 5 'class SessionTimeoutService|GetStatus\(|record SessionTimeoutStatus|class SessionTimeoutStatus|struct SessionTimeoutStatus' web VueApp
printf '%s\n' '--- current PR diff for SessionTimeout.vue and endpoint ---'
git diff --unified=25 24d17e137426fd8b608f59e7de21f02628442fa9 5c43bc7787b72a2f7bf29ccd53bd3ef6a08bf23b -- VueApp/src/components/SessionTimeout.vue web/Controllers/SessionTimeoutController.cs
printf '%s\n' '--- legacy timeout references and implementations ---'
rg -n -C 4 'seconds_until_timeout_v2|seconds_until_timeout|sessionTimeoutUrl|SessionTimeoutStatus' --glob '!node_modules' --glob '!dist' .Repository: ucdavis/VIPER Length of output: 19837 Do not treat the 600-second fallback as a valid timeout.
Return a distinct error response or status for the fallback. Retry that condition on a short interval without calling 🤖 Prompt for AI Agents |
||
| } else if (r.secondsUntilTimeout !== undefined) { | ||
| // Extended elsewhere, in another tab or by an API call, so stand the warning down. | ||
| hideSessionTimeoutWarning() | ||
| } | ||
| if (nextCheck > 0) { | ||
| sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, nextCheck * 1000) | ||
| } | ||
| }) | ||
| // Silent, but reschedule: one failed poll must not stop the checks for the life of the page. | ||
| // Retry quickly once the warning is up, since expiry is minutes away. | ||
| .catch(() => { | ||
| const retry = showSessionTimeoutWarning.value ? 15000 : 300000 | ||
| sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, retry) | ||
| }) | ||
| } | ||
|
|
||
| var d = new Date(r.sessionTimeoutDateTime) | ||
| sessionExpireTime.value = | ||
| (d.getHours() > 12 ? d.getHours() - 12 : d.getHours()) + | ||
| ":" + | ||
| ("0" + d.getMinutes()).slice(-2) + | ||
| (d.getHours() >= 12 ? " PM" : " AM") | ||
| async function extendSession() { | ||
| sessionExtendFailed.value = false | ||
| fetch(viperHome + "RefreshSession", { signal: AbortSignal.timeout(10000) }) | ||
| .then((r) => (r.ok ? r.json() : Promise.reject(new Error("RefreshSession returned " + r.status)))) | ||
| .then((r) => { | ||
| clearTimeout(sessionTimeoutCheckEventId) | ||
| sessionExpireTime.value = formatExpireTime(r.sessionTimeoutDateTime) | ||
| sessionReloaded.value = true | ||
| sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, 5000) | ||
|
|
||
| window.setTimeout(hideSessionTimeoutWarning, 1000) | ||
| }) | ||
| // Leave the dialog up so the user can retry or log in, and say so: the session was not extended. | ||
| .catch(() => { | ||
| sessionExtendFailed.value = true | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }) | ||
| } | ||
|
|
||
| function hideSessionTimeoutWarning() { | ||
| showSessionTimeoutWarning.value = false | ||
| sessionExpired.value = false | ||
| sessionReloaded.value = false | ||
| sessionExtendFailed.value = false | ||
| } | ||
|
|
||
| sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, 60000) | ||
|
|
@@ -106,7 +105,7 @@ sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, 60000) | |
| <div v-else-if="!sessionExpired">Your session has been extended to {{ sessionExpireTime }}.</div> | ||
| <q-space></q-space> | ||
| <LoginButton | ||
| v-if="sessionExpired" | ||
| v-if="sessionExpired || sessionExtendFailed" | ||
| dense | ||
| class="q-px-md" | ||
| /> | ||
|
|
@@ -129,6 +128,12 @@ sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, 60000) | |
| @click="hideSessionTimeoutWarning" | ||
| ></q-btn> | ||
| </q-card-section> | ||
| <q-card-section | ||
| v-if="sessionExtendFailed && !sessionExpired" | ||
| class="q-pt-none" | ||
| > | ||
| <StatusBanner type="error"> Could not extend your session. Please try again. </StatusBanner> | ||
| </q-card-section> | ||
| </q-card> | ||
| </q-dialog> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| using System.Globalization; | ||
| using Viper.Classes.Utilities; | ||
| using Viper.Models.VIPER; | ||
|
|
||
| namespace Viper.test.Classes | ||
| { | ||
| public class SessionTimeoutServiceTests | ||
| { | ||
| [Fact] | ||
| public void ToStatus_StoredRow_ReportsThatExpiryWithLocalOffset() | ||
| { | ||
| DateTime expiry = DateTime.Now.AddMinutes(10); | ||
|
|
||
| var status = SessionTimeoutService.ToStatus(new SessionTimeout { SessionTimeoutDateTime = expiry }, authenticated: true); | ||
|
|
||
| Assert.InRange(status.SecondsUntilTimeout, 590, 600); | ||
| var parsed = DateTimeOffset.ParseExact(status.SessionTimeoutDateTime, "yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture); | ||
| Assert.Equal(expiry.ToString("s"), parsed.DateTime.ToString("s")); | ||
| Assert.Equal(TimeZoneInfo.Local.GetUtcOffset(expiry), parsed.Offset); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ToStatus_AuthenticatedWithoutRow_GrantsFullWindow() | ||
| { | ||
| var status = SessionTimeoutService.ToStatus(null, authenticated: true); | ||
|
|
||
| Assert.Equal(SessionTimeoutService.SessionTimeoutSeconds, status.SecondsUntilTimeout); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ToStatus_Anonymous_ReportsExpired() | ||
| { | ||
| Assert.Equal(0, SessionTimeoutService.ToStatus(null, authenticated: false).SecondsUntilTimeout); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| using System.Reflection; | ||
| using System.Security.Claims; | ||
| using Microsoft.AspNetCore.Authentication; | ||
| using Microsoft.AspNetCore.Authentication.Cookies; | ||
| using Microsoft.AspNetCore.Http; | ||
| using Microsoft.AspNetCore.Mvc.Controllers; | ||
| using Viper.Classes; | ||
| using Viper.Controllers; | ||
|
|
||
| namespace Viper.test.Controllers | ||
| { | ||
| /// <summary> | ||
| /// The session expiry poll must never extend the session, or it would never time out. Both | ||
| /// controller bases in this app write a fresh expiry on every action, so the guarantee rests | ||
| /// entirely on this controller not inheriting either of them. Pin that. | ||
| /// </summary> | ||
| public class SessionTimeoutControllerTests | ||
| { | ||
| [Fact] | ||
| public void Controller_DoesNotInheritASessionExtendingBase() | ||
| { | ||
| Assert.False(typeof(ApiController).IsAssignableFrom(typeof(SessionTimeoutController))); | ||
| Assert.False(typeof(AreaController).IsAssignableFrom(typeof(SessionTimeoutController))); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void Controller_DoesNotCarryTheSessionUpdateFilter() | ||
| { | ||
| Assert.Empty(typeof(SessionTimeoutController) | ||
| .GetCustomAttributes(typeof(ApiSessionUpdateFilterAttribute), inherit: true)); | ||
|
|
||
| foreach (MethodInfo action in typeof(SessionTimeoutController).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) | ||
| { | ||
| Assert.Empty(action.GetCustomAttributes(typeof(ApiSessionUpdateFilterAttribute), inherit: true)); | ||
| } | ||
| } | ||
|
|
||
| [Theory] | ||
| [InlineData(typeof(SessionTimeoutController), false)] | ||
| [InlineData(typeof(HomeController), true)] | ||
| public async Task DoNotSlideCookie_RenewsOnlyForOtherEndpoints(Type controller, bool expectedRenew) | ||
| { | ||
| var httpContext = new DefaultHttpContext(); | ||
| var descriptor = new ControllerActionDescriptor { ControllerTypeInfo = controller.GetTypeInfo() }; | ||
| httpContext.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(descriptor), controller.Name)); | ||
| var ticket = new AuthenticationTicket(new ClaimsPrincipal(), CookieAuthenticationDefaults.AuthenticationScheme); | ||
| var context = new CookieSlidingExpirationContext(httpContext, | ||
| new AuthenticationScheme(CookieAuthenticationDefaults.AuthenticationScheme, null, typeof(CookieAuthenticationHandler)), | ||
| new CookieAuthenticationOptions(), ticket, TimeSpan.FromHours(7), TimeSpan.FromHours(5)) | ||
| { ShouldRenew = true }; | ||
|
|
||
| await SessionTimeoutController.DoNotSlideCookie(context); | ||
|
|
||
| Assert.Equal(expectedRenew, context.ShouldRenew); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| using Microsoft.AspNetCore.Authentication.Cookies; | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.AspNetCore.Mvc.Controllers; | ||
| using Microsoft.Data.SqlClient; | ||
| using NLog; | ||
| using Viper.Classes.SQLContext; | ||
| using Viper.Classes.Utilities; | ||
| using Viper.Models; | ||
|
|
||
| namespace Viper.Controllers | ||
| { | ||
| /// <summary> | ||
| /// Read-only session expiry poll for the session timeout dialog. The user comes from the auth cookie. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Inherits ControllerBase, not Viper.Classes.ApiController or AreaController: both extend the session on | ||
| /// every action. The [ApiController] attribute below is MVC's ApiControllerAttribute and does not. | ||
| /// [AllowAnonymous] so a caller whose cookie is gone gets 0 seconds and a Log in button, not a challenge redirect. | ||
| /// </remarks> | ||
| [ApiController] | ||
| [AllowAnonymous] | ||
| [Route("/api/sessionTimeout")] | ||
| [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] | ||
| public class SessionTimeoutController : ControllerBase | ||
| { | ||
| // Ten minutes rather than zero, so a database blip cannot strand the user behind a warning they cannot dismiss. | ||
| private const int SecondsOnError = 600; | ||
|
|
||
| private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); | ||
|
|
||
| private readonly VIPERContext _viperContext; | ||
|
|
||
| public SessionTimeoutController(VIPERContext viperContext) | ||
| { | ||
| _viperContext = viperContext; | ||
| } | ||
|
|
||
| // A background read must not slide the auth cookie either, or a tab left open on a page with no | ||
| // session row would poll forever and keep the login alive past its 12 hours. | ||
| internal static Task DoNotSlideCookie(CookieSlidingExpirationContext context) | ||
| { | ||
| var action = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<ControllerActionDescriptor>(); | ||
| if (action?.ControllerTypeInfo == typeof(SessionTimeoutController)) | ||
| { | ||
| context.ShouldRenew = false; | ||
| } | ||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| [HttpGet] | ||
| public SessionTimeoutStatus GetSessionTimeout() | ||
| { | ||
| try | ||
| { | ||
| return SessionTimeoutService.GetStatus(_viperContext); | ||
| } | ||
| catch (Exception ex) when (ex is SqlException or InvalidOperationException) | ||
| { | ||
| Logger.Error(ex, "Could not read session timeout"); | ||
| return new SessionTimeoutStatus(DateTime.Now.AddSeconds(SecondsOnError), SecondsOnError); | ||
| } | ||
| } | ||
| } | ||
| } |
This file was deleted.
Uh oh!
There was an error while loading. Please reload this page.