Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion test/Classes/AuthorizationFallbackTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class AuthorizationFallbackTests
// VIPER after signing out of Entra, with nothing but a 401 in the logs to show for it.
public static TheoryData<Type> AnonymousControllers() =>
new(typeof(CMSController), typeof(LayoutController), typeof(LoggedInUserController),
typeof(EntraLogoutController));
typeof(EntraLogoutController), typeof(SessionTimeoutController));

[Theory]
[MemberData(nameof(AnonymousControllers))]
Expand Down
36 changes: 36 additions & 0 deletions test/Classes/SessionTimeoutServiceTests.cs
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);
}
}
}
57 changes: 57 additions & 0 deletions test/Controllers/SessionTimeoutControllerTests.cs
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));
}
}
Comment thread
rlorenzo marked this conversation as resolved.
Resolved

[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);
}
}
}
29 changes: 20 additions & 9 deletions web/Classes/Utilities/SessionTimeoutService.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
using Microsoft.EntityFrameworkCore;
using NLog;
using Viper.Classes.SQLContext;
using Viper.Models;
using Viper.Models.AAUD;
using Viper.Models.VIPER;

namespace Viper.Classes.Utilities
{
public static class SessionTimeoutService
{
private const int SessionTimeoutSeconds = (29 * 60) + 30;
internal const int SessionTimeoutSeconds = (29 * 60) + 30;

public static void UpdateSessionTimeout(VIPERContext context)
{
Expand Down Expand Up @@ -45,19 +47,28 @@ public static void UpdateSessionTimeout(VIPERContext context)
}
}

public static SessionTimeout? GetSessionTimeout(VIPERContext context)
public static SessionTimeoutStatus GetStatus(VIPERContext context)
{
string loggedInUserId = GetLoggedInUserId();
string service = GetService();
if (!string.IsNullOrEmpty(loggedInUserId) && context != null)
SessionTimeout? record = string.IsNullOrEmpty(loggedInUserId)
? null
: context.SessionTimeouts.AsNoTracking()
.FirstOrDefault(s => s.LoginId == loggedInUserId && s.Service == service);
return ToStatus(record, HttpHelper.HttpContext?.User.Identity?.IsAuthenticated == true);
}

// Only AreaController, ApiSessionUpdateFilter and RefreshSession write the row, so a page served by a
// plain Controller leaves a valid session with no row: grant a full window rather than report it expired.
internal static SessionTimeoutStatus ToStatus(SessionTimeout? record, bool authenticated)
{
if (record != null)
{
SessionTimeout? record = context.SessionTimeouts.Find(loggedInUserId, service);
if (record != null)
{
return record;
}
return new(record.SessionTimeoutDateTime, (int)(record.SessionTimeoutDateTime - DateTime.Now).TotalSeconds);
}
return null;
return authenticated
? new(DateTime.Now.AddSeconds(SessionTimeoutSeconds), SessionTimeoutSeconds)
: new(DateTime.Now, 0);
}

private static string GetService()
Expand Down
2 changes: 1 addition & 1 deletion web/Controllers/HomeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ private bool IsUnderPathBase(string? url)
public IActionResult RefreshSession()
{
SessionTimeoutService.UpdateSessionTimeout(_viperContext);
return Ok(SessionTimeoutService.GetSessionTimeout(_viperContext));
return Ok(SessionTimeoutService.GetStatus(_viperContext));
}

/// <summary>
Expand Down
52 changes: 52 additions & 0 deletions web/Controllers/SessionTimeoutController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
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]
Comment thread
rlorenzo marked this conversation as resolved.
Resolved
[AllowAnonymous]
[Route("/api/sessionTimeout")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public class SessionTimeoutController : ControllerBase
Comment thread
rlorenzo marked this conversation as resolved.
Resolved
{
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()
{
// No catch: a database failure becomes a 500, which the client treats as a failed poll and retries.
// A made-up expiry here would read as a real one and could hide a live warning.
return SessionTimeoutService.GetStatus(_viperContext);
}
}
}
9 changes: 0 additions & 9 deletions web/Models/SessionTimeoutCheck.cs

This file was deleted.

22 changes: 22 additions & 0 deletions web/Models/SessionTimeoutStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Globalization;

namespace Viper.Models
{
/// <summary>
/// Session expiry contract polled by the session timeout dialog.
/// </summary>
public class SessionTimeoutStatus
{
public SessionTimeoutStatus(DateTime sessionTimeout, int secondsUntilTimeout)
{
// The column is a bare local datetime; carry the offset so a client in another timezone shows the right time.
DateTimeOffset local = new(DateTime.SpecifyKind(sessionTimeout, DateTimeKind.Local));
SessionTimeoutDateTime = local.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture);
SecondsUntilTimeout = secondsUntilTimeout;
}

public string SessionTimeoutDateTime { get; }

public int SecondsUntilTimeout { get; }
}
}
3 changes: 3 additions & 0 deletions web/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Net;

Check warning on line 1 in web/Program.cs

View workflow job for this annotation

GitHub Actions / Backend Tests

'<Main>$' has a cyclomatic complexity of '53'. Rewrite or refactor the code to decrease its complexity below '26'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1502)
using System.Reflection;
using System.Security.Claims;
using System.Text.Json.Serialization;
Expand Down Expand Up @@ -45,6 +45,7 @@
using Viper.Classes.Scheduler;
using Viper.Classes.SQLContext;
using Viper.Classes.Utilities;
using Viper.Controllers;
using Viper.EmailTemplates.Services;
using Viper.Services;
using Web;
Expand All @@ -53,7 +54,7 @@

// Load .env.local for local development only (multiple-instance support)
// Avoid loading in production - guard by ASPNETCORE_ENVIRONMENT.
var envPath = Path.Join(Directory.GetCurrentDirectory(), "../.env.local");

Check warning on line 57 in web/Program.cs

View workflow job for this annotation

GitHub Actions / Backend Tests

'Program' has a maintainability index of '0'. Rewrite or refactor the code to increase its maintainability index (MI) above '9'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1505)
var aspNetEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
if (string.Equals(aspNetEnv, "Development", StringComparison.OrdinalIgnoreCase)
&& File.Exists(envPath))
Expand Down Expand Up @@ -165,6 +166,8 @@

options.Events = new CookieAuthenticationEvents
{
OnCheckSlidingExpiration = SessionTimeoutController.DoNotSlideCookie,

// Front-channel logout cannot clear this cookie itself: Entra frames the logout URL
// on its own origin, and a SameSite=Lax cookie is neither sent nor accepted in that
// context. So the sign-out is recorded server side and enforced here instead, on
Expand Down
21 changes: 11 additions & 10 deletions web/Views/Shared/Components/SessionTimeout/Default.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
createVueApp({
data() {
return {
sessionRefreshUrl: '@Html.Raw(ViewData["sessionRefreshUrl"] ?? "")',
showSessionTimeoutWarning: false,
sessionExpireTime: 0,
sessionExpired: false,
Expand All @@ -45,7 +44,7 @@
// One live timer only: a refresh that overlaps an in-flight poll must not leave two chains running.
clearTimeout(this.sessionTimeoutCheckEventId)
// Timeout so a request that hangs rather than fails still reaches the catch below.
fetch(this.sessionRefreshUrl, { signal: AbortSignal.timeout(10000) })
fetch('@Url.Content("~/api/sessionTimeout")', { signal: AbortSignal.timeout(10000) })
.then(r => r.ok ? r.json() : Promise.reject(new Error('Session check returned ' + r.status)))
.then(r => {
var nextCheck = 300
Expand All @@ -58,12 +57,13 @@
if (r.secondsUntilTimeout !== undefined && r.secondsUntilTimeout <= 300) {
this.showSessionTimeoutWarning = true
this.sessionExpired = r.secondsUntilTimeout < 15
var d = new Date(r.sessionTimeoutDateTime)
this.sessionExpireTime = (d.getHours() > 12 ? d.getHours() - 12 : d.getHours()) + ":"
+ ("0" + d.getMinutes()).slice(-2)
+ (d.getHours() >= 12 ? " PM" : " AM")
this.sessionExpireTime = this.formatExpireTime(r.sessionTimeoutDateTime)
nextCheck = this.sessionExpired ? 0 : Math.max(r.secondsUntilTimeout - 15, 5)
}
else if (r.secondsUntilTimeout !== undefined) {
// Extended elsewhere, in another tab or by an API call, so stand the warning down.
this.hideSessionTimeoutWarning()
}
if(nextCheck > 0) {
this.sessionTimeoutCheckEventId = window.setTimeout(this.checkSessionTimeout, nextCheck * 1000)
}
Expand All @@ -84,10 +84,7 @@
.then(r => r.ok ? r.json() : Promise.reject(new Error('RefreshSession returned ' + r.status)))
.then(r => {
clearTimeout(this.sessionTimeoutCheckEventId)
var d = new Date(r.sessionTimeoutDateTime)
this.sessionExpireTime = (d.getHours() > 12 ? d.getHours() - 12 : d.getHours()) + ":"
+ ("0" + d.getMinutes()).slice(-2)
+ (d.getHours() >= 12 ? " PM" : " AM")
this.sessionExpireTime = this.formatExpireTime(r.sessionTimeoutDateTime)
this.sessionReloaded = true
this.sessionTimeoutCheckEventId = window.setTimeout(this.checkSessionTimeout, 5000)

Expand All @@ -98,9 +95,13 @@
},
hideSessionTimeoutWarning: function() {
this.showSessionTimeoutWarning = false
this.sessionExpired = false
this.sessionReloaded = false
this.sessionExtendFailed = false
},
formatExpireTime: function(sessionTimeoutDateTime) {
return new Date(sessionTimeoutDateTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
},
login: function() {
var returnUrl = encodeURIComponent(window.location.pathname + window.location.search)
window.location = '@Url.Content("~/login")?ReturnUrl=' + returnUrl
Expand Down
13 changes: 5 additions & 8 deletions web/Views/Shared/Components/SessionTimeout/SessionTimeout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,11 @@ public class SessionTimeout : ViewComponent
{
public IViewComponentResult Invoke()
{
UserHelper userHelper = new UserHelper();
string? loginId = userHelper.GetCurrentUser()?.LoginId;
bool onDev = HttpHelper.Environment?.EnvironmentName == "Development";
ViewData["sessionRefreshUrl"] = (onDev ? "http://localhost/" : ("https://" + HttpHelper.HttpContext?.Request.Host.Value + "/"))
+ "/public/timeout/seconds_until_timeout_v2.cfm?id="
+ (loginId ?? "")
+ "&service=" + (onDev ? "Viper2-dev" : "Viper2");
return View("Default");
// The layout renders this on public pages too. An anonymous visitor has no session, and
// the poll would report zero seconds left and tell them it had expired.
return UserClaimsPrincipal?.Identity?.IsAuthenticated == true
? View("Default")
: Content(string.Empty);
}

}
Expand Down
Loading