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
Empty file added .review-pr-ignored-304
Empty file.
33 changes: 33 additions & 0 deletions test/Controllers/SessionTimeoutControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Reflection;
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.
}
}
12 changes: 12 additions & 0 deletions web/Classes/SessionTimeoutStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Viper.Classes
{
/// <summary>
/// Session expiry contract polled by the session timeout dialog.
/// </summary>
public class SessionTimeoutStatus
{
public string SessionTimeoutDateTime { get; set; } = string.Empty;

public int SecondsUntilTimeout { get; set; }
}
}
6 changes: 4 additions & 2 deletions web/Classes/Utilities/SessionTimeoutService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using NLog;
using Viper.Classes.SQLContext;
using Viper.Models.AAUD;
Expand All @@ -7,7 +8,7 @@ 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 @@ -51,7 +52,8 @@ public static void UpdateSessionTimeout(VIPERContext context)
string service = GetService();
if (!string.IsNullOrEmpty(loggedInUserId) && context != null)
{
SessionTimeout? record = context.SessionTimeouts.Find(loggedInUserId, service);
SessionTimeout? record = context.SessionTimeouts.AsNoTracking()
.FirstOrDefault(s => s.LoginId == loggedInUserId && s.Service == service);
if (record != null)
{
return record;
Expand Down
94 changes: 94 additions & 0 deletions web/Controllers/SessionTimeoutController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using NLog;
using Viper.Classes;
using Viper.Classes.SQLContext;
using Viper.Classes.Utilities;

namespace Viper.Controllers
{
/// <summary>
/// Read-only session expiry poll for the session timeout dialog. Takes no parameters: the user
/// comes from the auth cookie.
/// </summary>
/// <remarks>
/// Inherits ControllerBase, not Viper.Classes.ApiController or Viper.Classes.AreaController.
/// Both of those extend the session on every action, which would stop the session ever expiring,
/// and ApiController also wraps responses in an ApiResponse envelope the dialog cannot read. The
/// [ApiController] attribute below is Microsoft.AspNetCore.Mvc.ApiControllerAttribute and
/// carries none of that behaviour.
///
/// No [Authorize]: attribute-routed actions do not pick up RequireAuthorization from the
/// conventional routes, and the action takes no parameters and reads identity from the cookie,
/// so an anonymous caller learns only that it has no session. The dialog then offers a log in
/// rather than retrying a call that would 401.
/// </remarks>
[ApiController]
Comment thread
rlorenzo marked this conversation as resolved.
[Route("/api/sessionTimeout")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public class SessionTimeoutController : ControllerBase
Comment thread
rlorenzo marked this conversation as resolved.
{
// Ten minutes rather than zero, so a database blip cannot strand the user behind a warning
// dialog 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;
}

[HttpGet]
public ActionResult<SessionTimeoutStatus> GetSessionTimeout()
{
try
{
Models.VIPER.SessionTimeout? record = SessionTimeoutService.GetSessionTimeout(_viperContext);
Comment thread
rlorenzo marked this conversation as resolved.
if (record != null)
{
return Status(record.SessionTimeoutDateTime,
(int)(record.SessionTimeoutDateTime - DateTime.Now).TotalSeconds);
}

// Authenticated but untracked. 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.
return User.Identity?.IsAuthenticated == true
? Status(DateTime.Now.AddSeconds(SessionTimeoutService.SessionTimeoutSeconds),
SessionTimeoutService.SessionTimeoutSeconds)
: Status(DateTime.Now, 0);
}
catch (SqlException ex)
{
return CouldNotRead(ex);
}
catch (InvalidOperationException ex)
{
return CouldNotRead(ex);
}
}

private static SessionTimeoutStatus CouldNotRead(Exception ex)
{
Logger.Error(ex, "Could not read session timeout");
return Status(DateTime.Now.AddSeconds(SecondsOnError), SecondsOnError);
}
Comment thread
rlorenzo marked this conversation as resolved.

// Carry the offset. The column is a bare datetime written from DateTime.Now, so it is local
// wall-clock; without the offset a client in another timezone reads it as its own and shows
// the wrong expiry time.
private static SessionTimeoutStatus Status(DateTime sessionTimeout, int secondsUntilTimeout)
{
DateTimeOffset local = new(DateTime.SpecifyKind(sessionTimeout, DateTimeKind.Local));
return new SessionTimeoutStatus
{
SessionTimeoutDateTime = local.ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture),
SecondsUntilTimeout = secondsUntilTimeout
};
}
}
}
9 changes: 0 additions & 9 deletions web/Models/SessionTimeoutCheck.cs

This file was deleted.

25 changes: 15 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 @@ -42,20 +41,21 @@
methods: {
checkSessionTimeout: async function () {
// 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
// "<=" so an exact 300 still warns: otherwise the next poll lands at expiry.
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 @@ -78,10 +78,7 @@
}
catch(e) { void e }

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 @@ -92,9 +89,17 @@
},
hideSessionTimeoutWarning: function() {
this.showSessionTimeoutWarning = false
this.sessionExpired = false
this.sessionReloaded = false
this.sessionExtendFailed = false
},
// Hour 0 is 12 AM, not 0 AM.
formatExpireTime: function(sessionTimeoutDateTime) {
var d = new Date(sessionTimeoutDateTime)
return (d.getHours() % 12 || 12) + ":"
+ ("0" + d.getMinutes()).slice(-2)
+ (d.getHours() >= 12 ? " PM" : " AM")
},
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