From bc9137d02d4682743b75c9dfe10ace2b34ff64e3 Mon Sep 17 00:00:00 2001 From: Matthew Kelly Date: Mon, 19 Feb 2024 09:01:32 +0000 Subject: [PATCH 1/3] #1241: initial work for enabling Tab Sessions --- examples/web-auth-form.ps1 | 6 +- src/Pode.psd1 | 4 + src/Private/Sessions.ps1 | 157 ++++++++++++++++++++++++++-------- src/Public/Authentication.ps1 | 14 +-- src/Public/Flash.ps1 | 12 +-- src/Public/Middleware.ps1 | 2 +- src/Public/Sessions.ps1 | 51 +++++++++-- tests/unit/Security.Tests.ps1 | 4 +- tests/unit/Sessions.Tests.ps1 | 12 +-- 9 files changed, 192 insertions(+), 70 deletions(-) diff --git a/examples/web-auth-form.ps1 b/examples/web-auth-form.ps1 index 6dda283a2..e9c5e57f9 100644 --- a/examples/web-auth-form.ps1 +++ b/examples/web-auth-form.ps1 @@ -37,7 +37,7 @@ Start-PodeServer -Threads 2 { if ($username -eq 'morty' -and $password -eq 'pickle') { return @{ User = @{ - ID ='M0R7Y302' + ID = 'M0R7Y302' Name = 'Morty' Type = 'Human' } @@ -55,8 +55,8 @@ Start-PodeServer -Threads 2 { Write-PodeViewResponse -Path 'auth-home' -Data @{ Username = $WebEvent.Auth.User.Name - Views = $session:Views - Expiry = Get-PodeSessionExpiry + Views = $session:Views + Expiry = Get-PodeSessionExpiry } } diff --git a/src/Pode.psd1 b/src/Pode.psd1 index c73165e3d..f722c1c92 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -204,6 +204,10 @@ 'Reset-PodeSessionExpiry', 'Get-PodeSessionDuration', 'Get-PodeSessionExpiry', + 'Test-PodeSessionsEnabled', + 'Get-PodeSessionTabId', + 'Get-PodeSessionInfo', + 'Test-PodeSessionScopeIsBrowser', # auth 'New-PodeAuthScheme', diff --git a/src/Private/Sessions.ps1 b/src/Private/Sessions.ps1 index 20da1f15b..32c8642dd 100644 --- a/src/Private/Sessions.ps1 +++ b/src/Private/Sessions.ps1 @@ -1,13 +1,43 @@ function New-PodeSession { + # sessionId + $sessionId = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.GenerateId -Return + + # tabId + $tabId = $null + if (!$PodeContext.Server.Sessions.Info.Scope.IsBrowser) { + $tabId = Get-PodeSessionTabId + } + + # return new session data return @{ Name = $PodeContext.Server.Sessions.Name - Id = (Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.GenerateId -Return) + Id = $sessionId + TabId = $tabId + FullId = (Get-PodeSessionFullId -SessionId $sessionId -TabId $tabId) Extend = $PodeContext.Server.Sessions.Info.Extend TimeStamp = [datetime]::UtcNow Data = @{} } } +function Get-PodeSessionFullId { + param( + [Parameter()] + [string] + $SessionId, + + [Parameter()] + [string] + $TabId + ) + + if (!$PodeContext.Server.Sessions.Info.Scope.IsBrowser -and ![string]::IsNullOrEmpty($TabId)) { + return "$($SessionId)-$($TabId)" + } + + return $SessionId +} + function ConvertTo-PodeSessionStrictSecret { param( [Parameter(Mandatory = $true)] @@ -23,9 +53,8 @@ function Set-PodeSession { throw 'there is no session available to set on the response' } + # convert secret to strict mode $secret = $PodeContext.Server.Sessions.Secret - - # covert secret to strict mode if ($PodeContext.Server.Sessions.Info.Strict) { $secret = ConvertTo-PodeSessionStrictSecret -Secret $secret } @@ -49,10 +78,11 @@ function Set-PodeSession { function Get-PodeSession { $secret = $PodeContext.Server.Sessions.Secret - $value = $null + $sessionId = $null + $tabId = Get-PodeSessionTabId $name = $PodeContext.Server.Sessions.Name - # covert secret to strict mode + # convert secret to strict mode if ($PodeContext.Server.Sessions.Info.Strict) { $secret = ConvertTo-PodeSessionStrictSecret -Secret $secret } @@ -65,8 +95,8 @@ function Get-PodeSession { } # get the header from the request - $value = Get-PodeHeader -Name $PodeContext.Server.Sessions.Name -Secret $secret - if ([string]::IsNullOrWhiteSpace($value)) { + $sessionId = Get-PodeHeader -Name $PodeContext.Server.Sessions.Name -Secret $secret + if ([string]::IsNullOrEmpty($sessionId)) { return $null } } @@ -80,19 +110,21 @@ function Get-PodeSession { # get the cookie from the request $cookie = Get-PodeCookie -Name $PodeContext.Server.Sessions.Name -Secret $secret - if ([string]::IsNullOrWhiteSpace($cookie)) { + if ([string]::IsNullOrEmpty($cookie)) { return $null } # get details from cookie $name = $cookie.Name - $value = $cookie.Value + $sessionId = $cookie.Value } # generate the session data $data = @{ Name = $name - Id = $value + Id = $sessionId + TabId = $tabId + FullId = (Get-PodeSessionFullId -SessionId $sessionId -TabId $tabId) Extend = $PodeContext.Server.Sessions.Info.Extend TimeStamp = $null Data = @{} @@ -107,7 +139,7 @@ function Revoke-PodeSession { return } - # remove from cookie + # remove from cookie if being used if (!$PodeContext.Server.Sessions.Info.UseHeaders) { Remove-PodeCookie -Name $WebEvent.Session.Name } @@ -157,32 +189,47 @@ function Set-PodeSessionHelpers { $WebEvent.Session | Add-Member -MemberType NoteProperty -Name Save -Value { param($check) - # the current session - $session = $WebEvent.Session - # do nothing if session has no ID - if ([string]::IsNullOrWhiteSpace($session.Id)) { + if ([string]::IsNullOrEmpty($WebEvent.Session.FullId)) { return } # only save if check and hashes different, but not if extending expiry or updated - if (!$session.Extend -and $check -and (Test-PodeSessionDataHash)) { + if (!$WebEvent.Session.Extend -and $check -and (Test-PodeSessionDataHash)) { return } # generate the expiry $expiry = Get-PodeSessionExpiry - # the data to save - which will be the data, some extra metadata + # the data to save - which will be the data, and some extra metadata like timestamp $data = @{ + Version = 3 Metadata = @{ - TimeStamp = $session.TimeStamp + TimeStamp = $WebEvent.Session.TimeStamp + } + Data = $WebEvent.Session.Data + } + + # save base session data to store + if (!$PodeContext.Server.Sessions.Info.Scope.IsBrowser -and $WebEvent.Session.TabId) { + $authData = @{ + Version = 3 + Metadata = @{ + TimeStamp = $WebEvent.Session.TimeStamp + Tabbed = $true + } + Data = @{ + Auth = $WebEvent.Session.Data.Auth + } } - Data = $session.Data + + $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Set -Arguments @($WebEvent.Session.Id, $authData, $expiry) -Splat + $data.Metadata['Parent'] = $WebEvent.Session.Id } # save session data to store - $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Set -Arguments @($session.Id, $data, $expiry) -Splat + $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Set -Arguments @($WebEvent.Session.FullId, $data, $expiry) -Splat # update session's data hash Set-PodeSessionDataHash @@ -190,14 +237,11 @@ function Set-PodeSessionHelpers { # delete the current session $WebEvent.Session | Add-Member -MemberType NoteProperty -Name Delete -Value { - # the current session - $session = $WebEvent.Session - # remove data from store - $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Delete -Arguments $session.Id + $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Delete -Arguments $WebEvent.Session.Id # clear session - $session.Clear() + $WebEvent.Session.Clear() } } @@ -211,6 +255,9 @@ function Get-PodeSessionInMemStore { $store | Add-Member -MemberType NoteProperty -Name Delete -Value { param($sessionId) $null = $PodeContext.Server.Sessions.Store.Memory.Remove($sessionId) + if (!$PodeContext.Server.Sessions.Info.Scope.IsBrowser) { + Invoke-PodeSchedule -Name '__pode_session_inmem_cleanup__' + } } # get a sessionId's data @@ -249,25 +296,30 @@ function Set-PodeSessionInMemClearDown { # cleardown expired inmem session every 10 minutes Add-PodeSchedule -Name '__pode_session_inmem_cleanup__' -Cron '0/10 * * * *' -ScriptBlock { + # do nothing if no sessions $store = $PodeContext.Server.Sessions.Store - if (Test-PodeIsEmpty $store.Memory) { + if (($null -eq $store.Memory) -or ($store.Memory.Count -eq 0)) { return } - # remove sessions that have expired + # remove sessions that have expired, or where the parent is gone $now = [DateTime]::UtcNow foreach ($key in $store.Memory.Keys) { + # expired if ($store.Memory[$key].Expiry -lt $now) { $null = $store.Memory.Remove($key) + continue + } + + # parent check - gone/expired + $parentKey = $store.Memory[$key].Data.Metadata.Parent + if ($parentKey -and (!$store.Memory.ContainsKey($parentKey) -or ($store.Memory[$parentKey].Expiry -lt $now))) { + $null = $store.Memory.Remove($key) } } } } -function Test-PodeSessionsConfigured { - return (($null -ne $PodeContext.Server.Sessions) -and ($PodeContext.Server.Sessions.Count -gt 0)) -} - function Test-PodeSessionsInUse { return (($null -ne $WebEvent.Session) -and ($WebEvent.Session.Count -gt 0)) } @@ -276,10 +328,38 @@ function Get-PodeSessionData { param( [Parameter()] [string] - $SessionId + $SessionId, + + [Parameter()] + [string] + $TabId = $null ) - return (Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Get -Arguments $SessionId -Return) + $data = $null + + # try and get Tab session + if (!$PodeContext.Server.Sessions.Info.Scope.IsBrowser -and ![string]::IsNullOrEmpty($TabId)) { + $data = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Get -Arguments "$($SessionId)-$($TabId)" -Return + + # now get the parent - but fail if it doesn't exist + if ($data.Metadata.Parent) { + $parent = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Get -Arguments $data.Metadata.Parent -Return + if (!$parent) { + return $null + } + + if (!$data.Data.Auth) { + $data.Data.Auth = $parent.Data.Auth + } + } + } + + # try and get normal session + if (($null -eq $data) -and ![string]::IsNullOrEmpty($SessionId)) { + $data = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Get -Arguments $SessionId -Return + } + + return $data } function Get-PodeSessionMiddleware { @@ -300,14 +380,19 @@ function Get-PodeSessionMiddleware { } # get the session's data from store - elseif ($null -ne ($data = (Get-PodeSessionData -SessionId $WebEvent.Session.Id))) { - if ($null -eq $data.Metadata) { + elseif ($null -ne ($data = (Get-PodeSessionData -SessionId $WebEvent.Session.Id -TabId $WebEvent.Session.TabId))) { + if ($data.Version -lt 3) { $WebEvent.Session.Data = $data $WebEvent.Session.TimeStamp = [datetime]::UtcNow } else { $WebEvent.Session.Data = $data.Data - $WebEvent.Session.TimeStamp = $data.Metadata.TimeStamp + if ($data.Metadata.Tabbed) { + $WebEvent.Session.TimeStamp = [datetime]::UtcNow + } + else { + $WebEvent.Session.TimeStamp = $data.Metadata.TimeStamp + } } } diff --git a/src/Public/Authentication.ps1 b/src/Public/Authentication.ps1 index 0451f5a80..77422d934 100644 --- a/src/Public/Authentication.ps1 +++ b/src/Public/Authentication.ps1 @@ -406,7 +406,7 @@ function New-PodeAuthScheme { throw 'OAuth2 requires an Authorise URL to be supplied' } - if ($UsePKCE -and !(Test-PodeSessionsConfigured)) { + if ($UsePKCE -and !(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use OAuth2 with PKCE' } @@ -749,7 +749,7 @@ function Add-PodeAuth { } # if we're using sessions, ensure sessions have been setup - if (!$Sessionless -and !(Test-PodeSessionsConfigured)) { + if (!$Sessionless -and !(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use session persistent authentication' } @@ -911,7 +911,7 @@ function Merge-PodeAuth { } # if we're using sessions, ensure sessions have been setup - if (!$Sessionless -and !(Test-PodeSessionsConfigured)) { + if (!$Sessionless -and !(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use session persistent authentication' } @@ -1242,7 +1242,7 @@ function Add-PodeAuthWindowsAd { } # if we're using sessions, ensure sessions have been setup - if (!$Sessionless -and !(Test-PodeSessionsConfigured)) { + if (!$Sessionless -and !(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use session persistent authentication' } @@ -1367,7 +1367,7 @@ function Add-PodeAuthSession { ) # if sessions haven't been setup, error - if (!(Test-PodeSessionsConfigured)) { + if (!(Test-PodeSessionsEnabled)) { throw 'Sessions have not been configured' } @@ -1815,7 +1815,7 @@ function Add-PodeAuthUserFile { } # if we're using sessions, ensure sessions have been setup - if (!$Sessionless -and !(Test-PodeSessionsConfigured)) { + if (!$Sessionless -and !(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use session persistent authentication' } @@ -1977,7 +1977,7 @@ function Add-PodeAuthWindowsLocal { } # if we're using sessions, ensure sessions have been setup - if (!$Sessionless -and !(Test-PodeSessionsConfigured)) { + if (!$Sessionless -and !(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use session persistent authentication' } diff --git a/src/Public/Flash.ps1 b/src/Public/Flash.ps1 index b646c761e..ca005ea8c 100644 --- a/src/Public/Flash.ps1 +++ b/src/Public/Flash.ps1 @@ -28,7 +28,7 @@ function Add-PodeFlashMessage { ) # if sessions haven't been setup, error - if (!(Test-PodeSessionsConfigured)) { + if (!(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use Flash messages' } @@ -60,7 +60,7 @@ function Clear-PodeFlashMessages { param() # if sessions haven't been setup, error - if (!(Test-PodeSessionsConfigured)) { + if (!(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use Flash messages' } @@ -94,7 +94,7 @@ function Get-PodeFlashMessage { ) # if sessions haven't been setup, error - if (!(Test-PodeSessionsConfigured)) { + if (!(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use Flash messages' } @@ -129,7 +129,7 @@ function Get-PodeFlashMessageNames { param() # if sessions haven't been setup, error - if (!(Test-PodeSessionsConfigured)) { + if (!(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use Flash messages' } @@ -163,7 +163,7 @@ function Remove-PodeFlashMessage { ) # if sessions haven't been setup, error - if (!(Test-PodeSessionsConfigured)) { + if (!(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use Flash messages' } @@ -196,7 +196,7 @@ function Test-PodeFlashMessage { ) # if sessions haven't been setup, error - if (!(Test-PodeSessionsConfigured)) { + if (!(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use Flash messages' } diff --git a/src/Public/Middleware.ps1 b/src/Public/Middleware.ps1 index 9fb4c4809..6ee06f80f 100644 --- a/src/Public/Middleware.ps1 +++ b/src/Public/Middleware.ps1 @@ -238,7 +238,7 @@ function Initialize-PodeCsrf { } # if sessions haven't been setup and we're not using cookies, error - if (!$UseCookies -and !(Test-PodeSessionsConfigured)) { + if (!$UseCookies -and !(Test-PodeSessionsEnabled)) { throw 'Sessions are required to use CSRF unless you want to use cookies' } diff --git a/src/Public/Sessions.ps1 b/src/Public/Sessions.ps1 index c7b487b38..7d865695a 100644 --- a/src/Public/Sessions.ps1 +++ b/src/Public/Sessions.ps1 @@ -74,7 +74,12 @@ function Enable-PodeSessionMiddleware { [Parameter()] [psobject] - $Storage, + $Storage = $null, + + [Parameter()] + [ValidateSet('Browser', 'Tab')] + [string] + $Scope = 'Browser', [switch] $Extend, @@ -96,7 +101,7 @@ function Enable-PodeSessionMiddleware { ) # check that session logic hasn't already been initialised - if (Test-PodeSessionsConfigured) { + if (Test-PodeSessionsEnabled) { throw 'Session Middleware has already been intialised' } @@ -138,12 +143,17 @@ function Enable-PodeSessionMiddleware { Strict = $Strict.IsPresent HttpOnly = $HttpOnly.IsPresent UseHeaders = $UseHeaders.IsPresent + Scope = @{ + Type = $Scope.ToLowerInvariant() + IsBrowser = ($Scope -ieq 'Browser') + } } } # return scriptblock for the session middleware - $script = Get-PodeSessionMiddleware - (New-PodeMiddleware -ScriptBlock $script) | Add-PodeMiddleware -Name '__pode_mw_sessions__' + Get-PodeSessionMiddleware | + New-PodeMiddleware | + Add-PodeMiddleware -Name '__pode_mw_sessions__' } <# @@ -161,11 +171,11 @@ function Remove-PodeSession { param() # if sessions haven't been setup, error - if (!(Test-PodeSessionsConfigured)) { + if (!(Test-PodeSessionsEnabled)) { throw 'Sessions have not been configured' } - # do nothin if session is null + # do nothing if session is null if ($null -eq $WebEvent.Session) { return } @@ -195,7 +205,7 @@ function Save-PodeSession { ) # if sessions haven't been setup, error - if (!(Test-PodeSessionsConfigured)) { + if (!(Test-PodeSessionsEnabled)) { throw 'Sessions have not been configured' } @@ -248,7 +258,7 @@ function Get-PodeSessionId { } # get the sessionId - $sessionId = $WebEvent.Session.Id + $sessionId = $WebEvent.Session.FullId # do they want the session signed? if ($Signed) { @@ -268,6 +278,17 @@ function Get-PodeSessionId { return $sessionId } +function Get-PodeSessionTabId { + [CmdletBinding()] + param() + + if ($PodeContext.Server.Sessions.Info.Scope.IsBrowser) { + return $null + } + + return Get-PodeHeader -Name 'X-PODE-SESSION-TAB-ID' +} + <# .SYNOPSIS Resets the current Session's expiry date. @@ -283,7 +304,7 @@ function Reset-PodeSessionExpiry { param() # if sessions haven't been setup, error - if (!(Test-PodeSessionsConfigured)) { + if (!(Test-PodeSessionsEnabled)) { throw 'Sessions have not been configured' } @@ -352,4 +373,16 @@ function Get-PodeSessionExpiry { # return expiry return $expiry +} + +function Test-PodeSessionsEnabled { + return (($null -ne $PodeContext.Server.Sessions) -and ($PodeContext.Server.Sessions.Count -gt 0)) +} + +function Get-PodeSessionInfo { + return $PodeContext.Server.Sessions.Info +} + +function Test-PodeSessionScopeIsBrowser { + return [bool]$PodeContext.Server.Sessions.Info.Scope.IsBrowser } \ No newline at end of file diff --git a/tests/unit/Security.Tests.ps1 b/tests/unit/Security.Tests.ps1 index 912b85291..559d7358d 100644 --- a/tests/unit/Security.Tests.ps1 +++ b/tests/unit/Security.Tests.ps1 @@ -427,7 +427,7 @@ Describe 'Initialize-PodeCsrf' { }}} Mock Test-PodeCsrfConfigured { return $false } - Mock Test-PodeSessionsConfigured { return $true } + Mock Test-PodeSessionsEnabled { return $true } Mock Get-PodeCookieSecret { return 'secret' } Initialize-PodeCsrf -IgnoreMethods @('Get') @@ -444,7 +444,7 @@ Describe 'Initialize-PodeCsrf' { }}} Mock Test-PodeCsrfConfigured { return $false } - Mock Test-PodeSessionsConfigured { return $false } + Mock Test-PodeSessionsEnabled { return $false } Mock Get-PodeCookieSecret { return 'secret' } Initialize-PodeCsrf -IgnoreMethods @('Get') -UseCookies diff --git a/tests/unit/Sessions.Tests.ps1 b/tests/unit/Sessions.Tests.ps1 index 7e685a89d..8201792a0 100644 --- a/tests/unit/Sessions.Tests.ps1 +++ b/tests/unit/Sessions.Tests.ps1 @@ -249,12 +249,12 @@ Describe 'Set-PodeSession' { Describe 'Remove-PodeSession' { It 'Throws an error if sessions are not configured' { - Mock Test-PodeSessionsConfigured { return $false } + Mock Test-PodeSessionsEnabled { return $false } { Remove-PodeSession } | Should Throw 'Sessions have not been configured' } It 'Does nothing if there is no session' { - Mock Test-PodeSessionsConfigured { return $true } + Mock Test-PodeSessionsEnabled { return $true } Mock Remove-PodeAuthSession {} $WebEvent = @{} @@ -264,7 +264,7 @@ Describe 'Remove-PodeSession' { } It 'Call removes the session' { - Mock Test-PodeSessionsConfigured { return $true } + Mock Test-PodeSessionsEnabled { return $true } Mock Remove-PodeAuthSession {} $WebEvent = @{ Session = @{} } @@ -276,18 +276,18 @@ Describe 'Remove-PodeSession' { Describe 'Save-PodeSession' { It 'Throws an error if sessions are not configured' { - Mock Test-PodeSessionsConfigured { return $false } + Mock Test-PodeSessionsEnabled { return $false } { Save-PodeSession } | Should Throw 'Sessions have not been configured' } It 'Throws error if there is no session' { - Mock Test-PodeSessionsConfigured { return $true } + Mock Test-PodeSessionsEnabled { return $true } $WebEvent = @{} { Save-PodeSession } | Should Throw 'There is no session available to save' } It 'Call saves the session' { - Mock Test-PodeSessionsConfigured { return $true } + Mock Test-PodeSessionsEnabled { return $true } Mock Invoke-PodeScriptBlock {} $WebEvent = @{ Session = @{ From c770db3d1ccb3a50401fbf0210bba1aca2b3de44 Mon Sep 17 00:00:00 2001 From: Matthew Kelly Date: Mon, 19 Feb 2024 22:45:37 +0000 Subject: [PATCH 2/3] #1241: Tab session docs, and session perf tweak --- docs/Tutorials/Middleware/Types/Sessions.md | 74 +++++++++++--- src/Private/Sessions.ps1 | 106 +++++++++----------- src/Public/Sessions.ps1 | 8 +- 3 files changed, 116 insertions(+), 72 deletions(-) diff --git a/docs/Tutorials/Middleware/Types/Sessions.md b/docs/Tutorials/Middleware/Types/Sessions.md index fc1c04e5d..f0fa5b939 100644 --- a/docs/Tutorials/Middleware/Types/Sessions.md +++ b/docs/Tutorials/Middleware/Types/Sessions.md @@ -2,23 +2,23 @@ Session Middleware is supported on web requests and responses in the form of signed a cookie/header and server-side data storage. When configured the middleware will check for a session cookie/header (usually called `pode.sid`) on the request; if a cookie/header is not found on the request, or the session is not in storage, then a new session is created and attached to the response. If there is a session, then the appropriate data for that session is loaded from storage. -The duration of the session cookie/header can be specified, as well as whether to extend the duration each time on each request. A secret-key to sign sessions can be supplied (default is a random GUID), as well as the ability to specify custom data stores - the default is in-memory, but custom storage could be anything like Redis/MongoDB/etc. +The duration of the session cookie/header can be specified, as well as whether to extend the duration each time on each request. A secret key to sign sessions can be supplied (default is a random GUID), as well as the ability to specify custom data stores - the default is in-memory, but custom storage could be anything like Redis/MongoDB/etc. !!! note Using sessions via headers is best used with REST APIs and the CLI. It's not advised to use them for normal websites, as browsers don't send back response headers in new requests - unlike cookies. !!! tip - Sessions are typically used in conjunction with Authentication, but can you use them standalone as well! + Sessions are typically used in conjunction with Authentication, but you can use them standalone as well! ## Usage -To initialise sessions in Pode you'll need to call [`Enable-PodeSessionMiddleware`](../../../../Functions/Sessions/Enable-PodeSessionMiddleware). This function will configure and automatically create the Middleware needed to enable sessions. By default sessions are set to use cookies, but support is also available for headers. +To initialise sessions in Pode you'll need to call [`Enable-PodeSessionMiddleware`](../../../../Functions/Sessions/Enable-PodeSessionMiddleware). This function will configure and automatically create the Middleware needed to enable sessions. By default, sessions are set to use cookies, but support is also available for headers. Sessions are automatically signed using a random GUID. For Pode running on a single server using the default in-memory storage this is OK, however if you're running Pode on multiple servers, or if you're defining a custom storage then a `-Secret` is required - this is so that sessions from different servers, or after a server restart, don't become corrupt and unusable. ### Cookies -The following is an example of how to setup session middleware using cookies. The duration of each session is defined as a total number of seconds via the `-Duration` parameter; here we set the duration to 120, so each session created will expire after 2mins, but the expiry time will be extended each time the session is used: +The following is an example of how to set up session middleware using cookies. The duration of each session is defined as a total number of seconds via the `-Duration` parameter; here we set the duration to 120, so each session created will expire after 2mins, but the expiry time will be extended each time the session is used: ```powershell Start-PodeServer { @@ -30,7 +30,7 @@ The default name of the session cookie is `pode.sid`, but this can be customised ### Headers -Sessions are also supported using headers - useful for CLI requests. The following example will enable sessions use headers instead of cookies, and will also set each session created to have a `-Duration` of 120 seconds: +Sessions are also supported using headers - useful for CLI requests. The following example will enable sessions to use headers instead of cookies, and will also set each session created to have a `-Duration` of 120 seconds: ```powershell Start-PodeServer { @@ -40,13 +40,54 @@ Start-PodeServer { When using headers, the default name of the session header in the request/response is `pode.sid` - this can be customised using the `-Name` parameter. When you make an initial request to authenticate some user, the `pode.sid` header will be returned in the response. You can then use the value of this header in subsequent requests for the authenticated user, and then make a call using the session one last time against some route to expire the session - or just let it automatically expire. +## Scope + +Sessions have two different Scopes: Browser and Tab. You can specify the scope using the `-Scope` parameter on [`Enable-PodeSessionMiddleware`](../../../../Functions/Sessions/Enable-PodeSessionMiddleware). + +!!! important + Using the Tabs scope requires additional frontend logic, and doesn't work out-of-the-box like the Browser scope. See more [below](#tabs). + +The default Scope is Browser, where any authentication and general session data is shared across all tabs in a browser. If you have a site with a view counter and you log in to your site on one tab, and then open another tab and navigate to your site, you'll be automatically logged in and see the same view counter results in both tabs. + +The Tabs scope still shares authentication data, so if you log in to your site in one tab and open it again in a different tab, you'll still be logged in. However, general data is separated; taking the view counter example above, both tabs will show different results for the view counter. + +### Tabs + +Unlike the Browser scope which works out-of-the-box when enabled, the Tabs scope requires additional frontend logic. + +For session data to be split across different tabs, you need to inform Pode about what the "TabId" is for each tab. This is done by supplying an `X-PODE-SESSION-TAB-ID` HTTP header in the request. From a browser on a normal page request, there's no way to supply this header, and the normal base session will be supplied instead - hence why authentication data remains shared across tabs. However, if you load the content of your site asynchronously, or via any other means, you can supply this header and it will let you split general session data across tabs. + +In websites, the TabId is typically generated via JavaScript, and stored in `window.sessionStorage`. However, you have to be careful with this approach, as it does make tab sessions susceptible to XSS attacks. The following is a similar approach as used by Pode.Web: + +```javascript +// set TabId +if (window.sessionStorage.TabId) { + window.TabId = window.sessionStorage.TabId; + window.sessionStorage.removeItem("TabId"); +} +else { + window.TabId = Math.floor(Math.random() * 1000000); +} + +// binding to persist TabId on refresh +window.addEventListener("beforeunload", function(e) { + window.sessionStorage.TabId = window.TabId; + return null; +}); +``` + +The TabId could then be sent as an HTTP header using AJAX. There are other approaches available online as well. + ## SessionIds -The inbuilt SessionId generator used for sessions is a GUID, but you can supply a custom generator using the `-Generator` parameter. +The built-in SessionId generator used for sessions is a GUID, but you can supply a custom generator using the `-Generator` parameter. If supplied, the `-Generator` is a scriptblock that must return a valid string. The string itself should be a random unique value, that can be used as a unique session identifier. -Within a route, or middleware, you can get the currently authenticated session'd ID using [`Get-PodeSessionId`](../../../../Functions/Sessions/Get-PodeSessionId). If there is no session, or the session is not authenticated, then `$null` is returned. This function can also returned the fully signed sessionId as well. If you want the sessionId even if it's not authenticated, then you can supply `-Force` to get the current SessionId back. +Within a route, or middleware, you can get the currently authenticated session's ID using [`Get-PodeSessionId`](../../../../Functions/Sessions/Get-PodeSessionId). If there is no session, or the session is not authenticated, then `$null` is returned. This function can also return the fully signed sessionId as well. If you want the sessionId even if it's not authenticated, then you can supply `-Force` to get the current SessionId back. + +!!! note + If you're using the Tab `-Scope`, then the SessionId will include the TabId as well, if one was supplied. ### Strict @@ -66,7 +107,7 @@ You can define a custom storage by supplying a `psobject` to the `-Storage` para [void] Delete([string] $sessionId) ``` -For example, the following is a mock up of a Storage for Redis (note that the functions are fake): +For example, the following is a mock-up of a Storage for Redis (note that the functions are fake): ```powershell # create the object @@ -97,7 +138,7 @@ Enable-PodeSessionMiddleware -Duration 120 -Storage $store -Secret 'schwifty' ## Session Data -To add data to a session you can utilise the `.Session.Data` property within the [web event](../../../WebEvent) object accessible in a Route - or other Middleware. The data will be saved to some storage at the end of the route automatically using Endware. When a request is made using the same SessionId, the data is loaded from the store. For example, incrementing some view counter: +To add data to a session you can use the `.Session.Data` property within the [web event](../../../WebEvent) object, which is accessible in a Route or other Middleware. The data will be saved to some storage at the end of the request automatically using Endware. When a request is made using the same SessionId, the data is loaded from the store. For example, incrementing some view counter: ```powershell Add-PodeRoute -Method Get -Path '/' -ScriptBlock { @@ -105,7 +146,7 @@ Add-PodeRoute -Method Get -Path '/' -ScriptBlock { } ``` -You can also use the `$session:` variable scope, which will get/set data on the current session for the name supplied. You can use `$session:` anywhere a `$WebEvent` is available - such as Routes, Middleware, Authentication and Endware. The same view counter example above would now be as follows: +You can also use the `$session:` variable scope, which will get/set data on the current session for the name supplied. You can use `$session:` anywhere a `$WebEvent` is available - such as Routes, Middleware, Authentication, and Endware. The same view counter example above would now be as follows: ```powershell Add-PodeRoute -Method Get -Path '/' -ScriptBlock { @@ -116,11 +157,14 @@ Add-PodeRoute -Method Get -Path '/' -ScriptBlock { A session's data will be automatically saved by Pode at the end of each request, but you can force the data of the current session to be saved by using [`Save-PodeSession`](../../../../Functions/Sessions/Save-PodeSession). !!! important - `$session:` can only be used in the main scriptblocks of Routes, etc. If you attempt to use it in a function of a custom module, it will fail; even if you're using the function in a route. Pode remaps `$session:` on server start, and can only do this to the main scriptblocks supplied to functions such as `Add-PodeRoute`. In these scenarios you will have to use `$WebEvent.Session.Data`. + `$session:` can only be used in the main scriptblocks of Routes, etc. If you attempt to use it in a function of a custom module, it will fail; even if you're using the function in a route. Pode remaps `$session:` on server start, and can only do this to the main scriptblocks supplied to functions such as `Add-PodeRoute`. In these scenarios, you will have to use `$WebEvent.Session.Data`. + +!!! note + If using the Tab `-Scope`, any session data will be stored separately from other tabs. This allows you to have multiple tabs open for the same site/page but all with separate session data. Any authentication data will still be shared. ## Expiry -When you enable Sessions using [`Enable-PodeSessionMiddleware`](../../../../Functions/Sessions/Enable-PodeSessionMiddleware) you can define the duration of each session created, in seconds, using the `-Duration` parameter. When a session is created its expiry is set to `DateTime.UtcNow + Duration`, and by default a session will automatically expire when the calculated DateTime is reached: +When you enable Sessions using [`Enable-PodeSessionMiddleware`](../../../../Functions/Sessions/Enable-PodeSessionMiddleware) you can define the duration of each session created, in seconds, using the `-Duration` parameter. When a session is created its expiry is set to `DateTime.UtcNow + Duration`, and by default, a session will automatically expire when the calculated DateTime is reached: ```powershell Start-PodeServer { @@ -128,7 +172,7 @@ Start-PodeServer { } ``` -You can tell Pode to reset/extend each session's expiry on each request sent, that uses that SessionId, by passing the `-Extend` switch. When a session's expiry is reset/extended, the DateTime/Duration calculation is re-calculated: +You can tell Pode to reset/extend each session's expiry on each request sent, that uses that SessionId, by supplying the `-Extend` switch. When a session's expiry is reset/extended, the DateTime/Duration calculation is re-calculated: ```powershell Start-PodeServer { @@ -138,7 +182,7 @@ Start-PodeServer { ### Retrieve -You can retrieve the expiry for the current session by using [`Get-PodeSessionExpiry`](../../../../Functions/Sessions/Get-PodeSessionExpiry). If you use this function without `-Extend` specified originally then this will return the explicit DateTime the current session will expire. However, if you did setup sessions to extend the this function will return the recalculated expiry for the current session on each call: +You can retrieve the expiry for the current session by using [`Get-PodeSessionExpiry`](../../../../Functions/Sessions/Get-PodeSessionExpiry). If you use this function without `-Extend` specified originally then this will return the explicit DateTime the current session will expire. However, if you did set up sessions to extend then this function will return the recalculated expiry for the current session on each call: ```powershell Start-PodeServer { @@ -168,7 +212,7 @@ Start-PodeServer { ### Reset -For any session created when `-Extend` wasn't supplied to [`Enable-PodeSessionMiddleware`](../../../../Functions/Sessions/Enable-PodeSessionMiddleware) will always have a explicit DateTime set for expiring. However, you can reset this expiry date using [`Reset-PodeSessionExpiry`](../../../../Functions/Sessions/Reset-PodeSessionExpiry), and the current session's expiry will be recalculated from now plus the specifed `-Duration`: +For any session created when `-Extend` wasn't supplied to [`Enable-PodeSessionMiddleware`](../../../../Functions/Sessions/Enable-PodeSessionMiddleware) will always have an explicit DateTime set for expiring. However, you can reset this expiry date using [`Reset-PodeSessionExpiry`](../../../../Functions/Sessions/Reset-PodeSessionExpiry), and the current session's expiry will be recalculated from now plus the specified `-Duration`: ```powershell Start-PodeServer { diff --git a/src/Private/Sessions.ps1 b/src/Private/Sessions.ps1 index 32c8642dd..6c9a8b03d 100644 --- a/src/Private/Sessions.ps1 +++ b/src/Private/Sessions.ps1 @@ -120,7 +120,7 @@ function Get-PodeSession { } # generate the session data - $data = @{ + return @{ Name = $name Id = $sessionId TabId = $tabId @@ -129,8 +129,6 @@ function Get-PodeSession { TimeStamp = $null Data = @{} } - - return $data } function Revoke-PodeSession { @@ -145,10 +143,7 @@ function Revoke-PodeSession { } # remove session from store - $null = Invoke-PodeScriptBlock -ScriptBlock $WebEvent.Session.Delete - - # blank the session - $WebEvent.Session.Clear() + Remove-PodeSessionInternal } function Set-PodeSessionDataHash { @@ -180,69 +175,69 @@ function Test-PodeSessionDataHash { return ($WebEvent.Session.DataHash -eq $hash) } -function Set-PodeSessionHelpers { - if ($null -eq $WebEvent.Session) { - throw 'No session available to set helpers' +function Save-PodeSessionInternal { + param( + [switch] + $Force + ) + + # do nothing if session has no ID + if ([string]::IsNullOrEmpty($WebEvent.Session.FullId)) { + return } - # force save a session's data to the store - $WebEvent.Session | Add-Member -MemberType NoteProperty -Name Save -Value { - param($check) + # only save if check and hashes different, but not if extending expiry or updated + if (!$WebEvent.Session.Extend -and $Force -and (Test-PodeSessionDataHash)) { + return + } - # do nothing if session has no ID - if ([string]::IsNullOrEmpty($WebEvent.Session.FullId)) { - return - } + # generate the expiry + $expiry = Get-PodeSessionExpiry - # only save if check and hashes different, but not if extending expiry or updated - if (!$WebEvent.Session.Extend -and $check -and (Test-PodeSessionDataHash)) { - return + # the data to save - which will be the data, and some extra metadata like timestamp + $data = @{ + Version = 3 + Metadata = @{ + TimeStamp = $WebEvent.Session.TimeStamp } + Data = $WebEvent.Session.Data + } - # generate the expiry - $expiry = Get-PodeSessionExpiry - - # the data to save - which will be the data, and some extra metadata like timestamp - $data = @{ + # save base session data to store + if (!$PodeContext.Server.Sessions.Info.Scope.IsBrowser -and $WebEvent.Session.TabId) { + $authData = @{ Version = 3 Metadata = @{ TimeStamp = $WebEvent.Session.TimeStamp + Tabbed = $true + } + Data = @{ + Auth = $WebEvent.Session.Data.Auth } - Data = $WebEvent.Session.Data } - # save base session data to store - if (!$PodeContext.Server.Sessions.Info.Scope.IsBrowser -and $WebEvent.Session.TabId) { - $authData = @{ - Version = 3 - Metadata = @{ - TimeStamp = $WebEvent.Session.TimeStamp - Tabbed = $true - } - Data = @{ - Auth = $WebEvent.Session.Data.Auth - } - } + $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Set -Arguments @($WebEvent.Session.Id, $authData, $expiry) -Splat + $data.Metadata['Parent'] = $WebEvent.Session.Id + } - $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Set -Arguments @($WebEvent.Session.Id, $authData, $expiry) -Splat - $data.Metadata['Parent'] = $WebEvent.Session.Id - } + # save session data to store + $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Set -Arguments @($WebEvent.Session.FullId, $data, $expiry) -Splat - # save session data to store - $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Set -Arguments @($WebEvent.Session.FullId, $data, $expiry) -Splat + # update session's data hash + Set-PodeSessionDataHash +} - # update session's data hash - Set-PodeSessionDataHash +function Remove-PodeSessionInternal { + if ($null -eq $WebEvent.Session) { + return } - # delete the current session - $WebEvent.Session | Add-Member -MemberType NoteProperty -Name Delete -Value { - # remove data from store - $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Delete -Arguments $WebEvent.Session.Id + # remove data from store + $null = Invoke-PodeScriptBlock -ScriptBlock $PodeContext.Server.Sessions.Store.Delete -Arguments $WebEvent.Session.Id - # clear session - $WebEvent.Session.Clear() - } + # clear session + $WebEvent.Session.Clear() + $WebEvent.Session = $null } function Get-PodeSessionInMemStore { @@ -405,9 +400,6 @@ function Get-PodeSessionMiddleware { # set data hash Set-PodeSessionDataHash - # add helper methods to current session - Set-PodeSessionHelpers - # add session to response if it's new or extendible if ($new -or $WebEvent.Session.Extend) { Set-PodeSession @@ -416,7 +408,9 @@ function Get-PodeSessionMiddleware { # assign endware for session to set cookie/header $WebEvent.OnEnd += @{ Logic = { - Save-PodeSession -Force + if ($null -ne $WebEvent.Session) { + Save-PodeSession -Force + } } } } diff --git a/src/Public/Sessions.ps1 b/src/Public/Sessions.ps1 index 7d865695a..0e1107ff9 100644 --- a/src/Public/Sessions.ps1 +++ b/src/Public/Sessions.ps1 @@ -21,6 +21,12 @@ A custom ScriptBlock to generate a random unique SessionId. The value returned m .PARAMETER Storage A custom PSObject that defines methods for Delete, Get, and Set. This allow you to store Sessions in custom Storage such as Redis. A Secret is required. +.PARAMETER Scope +The Scope that the Session applies to, possible values are Browser and Tab (Default: Browser). +The Browser scope is the default logic, where authentication and general data for the sessions are shared across all tabs. +The Tab scope keep the authentication data shared across all tabs, but general data is separated across different tabs. +For the Tab scope, the "Tab ID" required will be sourced from the "X-PODE-SESSION-TAB-ID" header. + .PARAMETER Extend If supplied, the Sessions will have their durations extended on each successful Request. @@ -220,7 +226,7 @@ function Save-PodeSession { } # save the session - $null = Invoke-PodeScriptBlock -ScriptBlock $WebEvent.Session.Save -Arguments @($Force.IsPresent) -Splat + Save-PodeSessionInternal -Force:$Force } <# From 54bb76bdb85ab4d6032f3f21d6be517e2d688d5d Mon Sep 17 00:00:00 2001 From: Matthew Kelly Date: Mon, 19 Feb 2024 23:07:35 +0000 Subject: [PATCH 3/3] #1241: test fix --- tests/unit/Sessions.Tests.ps1 | 57 ++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/tests/unit/Sessions.Tests.ps1 b/tests/unit/Sessions.Tests.ps1 index 8201792a0..266a634f5 100644 --- a/tests/unit/Sessions.Tests.ps1 +++ b/tests/unit/Sessions.Tests.ps1 @@ -37,11 +37,11 @@ Describe 'Get-PodeSession' { $PodeContext = @{ Server = @{ - Cookies = @{} + Cookies = @{} Sessions = @{ - Name = 'pode.sid' + Name = 'pode.sid' Secret = 'key' - Info = @{ 'Duration' = 60 } + Info = @{ 'Duration' = 60 } } } } @@ -54,16 +54,17 @@ Describe 'Get-PodeSession' { $cookie = [System.Net.Cookie]::new('pode.sid', 's:value.kPv88V5o2uJ29sqh2a7P/f3dxcg+JdZJZT3GTIE=') $WebEvent = @{ Cookies = @{ - 'pode.sid' = $cookie - } } + 'pode.sid' = $cookie + } + } $PodeContext = @{ Server = @{ - Cookies = @{} + Cookies = @{} Sessions = @{ - Name = 'pode.sid' + Name = 'pode.sid' Secret = 'key' - Info = @{ 'Duration' = 60 } + Info = @{ 'Duration' = 60 } } } } @@ -76,16 +77,17 @@ Describe 'Get-PodeSession' { $cookie = [System.Net.Cookie]::new('pode.sid', 's:value.kPv88V50o2uJ29sqch2a7P/f3dxcg+J/dZJZT3GTJIE=') $WebEvent = @{ Cookies = @{ - 'pode.sid' = $cookie - } } + 'pode.sid' = $cookie + } + } $PodeContext = @{ Server = @{ - Cookies = @{} + Cookies = @{} Sessions = @{ - Name = 'pode.sid' + Name = 'pode.sid' Secret = 'key' - Info = @{ 'Duration' = 60 } + Info = @{ 'Duration' = 60 } } } } @@ -146,11 +148,11 @@ Describe 'New-PodeSession' { $PodeContext = @{ Server = @{ - Cookies = @{} + Cookies = @{} Sessions = @{ - Name = 'pode.sid' - Secret = 'key' - Info = @{ 'Duration' = 60 } + Name = 'pode.sid' + Secret = 'key' + Info = @{ 'Duration' = 60 } GenerateId = {} } } @@ -165,7 +167,7 @@ Describe 'New-PodeSession' { $WebEvent.Session.Data.Count | Should Be 0 $crypto = [System.Security.Cryptography.SHA256]::Create() - $hash = $crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes(($WebEvent.Session.Data| ConvertTo-Json -Depth 10 -Compress))) + $hash = $crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes(($WebEvent.Session.Data | ConvertTo-Json -Depth 10 -Compress))) $hash = [System.Convert]::ToBase64String($hash) $WebEvent.Session.DataHash | Should Be $hash @@ -191,12 +193,12 @@ Describe 'Test-PodeSessionDataHash' { It 'Returns true for a valid hash' { $WebEvent = @{ Session = @{ - 'Data' = @{ 'Counter' = 2; }; + 'Data' = @{ 'Counter' = 2; } } } $crypto = [System.Security.Cryptography.SHA256]::Create() - $hash = $crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes(($WebEvent.Session.Data| ConvertTo-Json -Depth 10 -Compress))) + $hash = $crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes(($WebEvent.Session.Data | ConvertTo-Json -Depth 10 -Compress))) $hash = [System.Convert]::ToBase64String($hash) $WebEvent.Session.DataHash = $hash @@ -234,9 +236,9 @@ Describe 'Set-PodeSession' { $WebEvent = @{ Session = @{ - 'Name' = 'name'; - 'Id' = 'sessionId'; - 'Cookie' = @{}; + 'Name' = 'name' + 'Id' = 'sessionId' + 'Cookie' = @{} } } @@ -288,13 +290,14 @@ Describe 'Save-PodeSession' { It 'Call saves the session' { Mock Test-PodeSessionsEnabled { return $true } - Mock Invoke-PodeScriptBlock {} + Mock Save-PodeSessionInternal {} $WebEvent = @{ Session = @{ - Save = {} - } } + Save = {} + } + } Save-PodeSession - Assert-MockCalled Invoke-PodeScriptBlock -Times 1 -Scope It + Assert-MockCalled Save-PodeSessionInternal -Times 1 -Scope It } } \ No newline at end of file