diff --git a/README.md b/README.md index deb4a31fb..88eee440d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ Pode is a Cross-Platform PowerShell framework that allows you to host [REST APIs * [Middleware](#middleware) * [Order of Running](#order-of-running) * [Overriding Inbuilt Logic](#overriding-inbuilt-logic) + * [Sessions](#sessions) + * [Authentication](#authentication) + * [Basic](#basic-auth) + * [Form](#form-auth) + * [Custom](#custom-auth) * [SMTP Server](#smtp-server) * [Misc](#misc) * [Logging](#logging) @@ -70,6 +75,8 @@ Pode is a Cross-Platform PowerShell framework that allows you to host [REST APIs * Basic rate limiting for IP addresses and subnets * Support for generating/binding self-signed certificates, and binding signed certificates * Support for middleware on web servers +* Session middleware support on web requests +* Can use authentication on requests, which can either be sessionless or session persistant ## Install @@ -480,6 +487,8 @@ Middleware in Pode is executed in a specific order, this order of running is as * Route middleware - runs any `route` middleware for the current route being processed * Route - finally, the route itself is processed +> This order will be fully customisable in future releases, which will also remove the overriding logic below + #### Overriding Inbuilt Logic Pode has some inbuilt middleware, as defined in the order of running above. Sometimes you probably don't want to use the inbuilt rate limiting, and use a custom rate limiting library that utilises REDIS. Each of the inbuilt middlewares have a defined name, that you can pass to the `middleware` function: @@ -512,6 +521,209 @@ Server { } ``` +#### Sessions + +Session `middleware` is supported in Pode on web requests/responses, in the form of signed-cookies and server-side data storage. When configured, the middleware will check for a session-cookie on the request; if a cookie is not found on the request, or the session is not in the store, then a new session is created and attached to the response. If there is a session, then the appropriate data is loaded from the store. + +The age of the session-cookie can be specified (and whether to extend the duration each time), as well as a secret-key to sign cookies, and the ability to specify custom data stores - the default is in-mem, custom could be anything like redis/mongo. + +The following is an example of how to setup session middleware: + +```powershell +Server { + + middleware (session @{ + 'Secret' = 'schwifty'; # secret-key used to sign session cookie + 'Name' = 'pode.sid'; # session cookie name (def: pode.sid) + 'Duration' = 120; # duration of the cookie, in seconds + 'Extend' = $true; # extend the duration of the cookie on each call + 'GenerateId' = { # custom SessionId generator (def: guid) + return [System.IO.Path]::GetRandomFileName() + }; + 'Store' = $null; # custom object with required methods (def: in-mem) + }) + +} +``` + +##### GenerateId + +If supplied, the `GenerateId` must be a scriptblock that returns a valid string. The string itself should be a random unique value, that can be used as a session identifier. The default `sessionId` is a `guid`. + +##### Store + +If supplied, the `Store` must be a valid object with the following required functions: + +```powershell +[hashtable] Get([string] $sessionId) +[void] Set([string] $sessionId, [hashtable] $data, [datetime] $expiry) +[void] Delete([string] $sessionId) +``` + +If no store is supplied, then a default in-memory store is used - with auto-cleanup for expired sessions. + +To add data to a session you can utilise the `.Session.Data` object within a `route`. The data will be saved at the end of the route logic autmoatically using `endware`. When a request comes in using the same session, the data is loaded from the store. An example of using a `session` in a `route` to increment a views counter could be as follows (the counter will continue to increment on each call to the route until the session expires): + +```powershell +Server { + + route 'get' '/' { + param($s) + $s.Session.Data.Views++ + json @{ 'Views' = $s.Session.Data.Views } + } + +} +``` + +### Authentication + +Using middleware and sessions, Pode has support for authentication on web requests. This authentication can either be session-persistant (ie, logins on websites), or sessionless (ie, auths on rest api calls). Examples of both types can be seen in the `web-auth-basic.ps1` and `web-auth-forms.ps1` example scripts. + +To use authentication in Pode there are two key commands: `auth use` and `auth check`. + +* `auth use` is used to setup an auth type (basic/form/custom); this is where you specify a validator script (to check the user exists in your storage), any options, and if using a custom type a parser script (to parse headers/payloads to pass to the validator). An example: + + ```powershell + Server { + # auth use -v {} [-o @{}] + + auth use basic -v { + param($user, $pass) + # logic to check user + return @{ 'user' = $user } + } + } + ``` + + The validator (`-v`) script is used to find a user, checking if they exist and the password is correct. If the validator passes, then a `user` needs to be returned from the script via `@{ 'user' = $user }` - if `$null` or a null user are returned then the validator is assumed to have failed, and a 401 status will be thrown. + + Some auth methods also have options (`-o`) that can be supplied as a hashtable, such as field name or encoding overrides - more below. + +* `auth check` is used in `route` calls, to check a specific auth method against the incoming request. If the validator defined in `auth use` returns no user, then the check fails with a 401 status; if a user is found, then it is set against the session (if session middleware is enabled) and the route logic is invoked. An example: + + ```powershell + Server { + # auth check [-o @{}] + + route get '/users' (auth check basic) { + param($session) + # route logic + } + } + ``` + + This is the most simple call to check authentication, the call also accepts options (`-o`) in a hashtable: + + | Name | Description | + | --- | ----------- | + | FailureUrl | URL to redirect to should auth fail | + | SuccessUrl | URL to redirect to should auth succeed | + | Session | When true: check if the session already has a validated user, and store the validated user in the session (def: true) | + | Login | When true: check the auth status in session and redirect to SuccessUrl, else proceed to the page with no auth required (def: false) | + | Logout | When true: purge the session and redirect to the FailureUrl (def: false) | + +If you have defined session-middleware to be used in your script, then when an `auth check` call succeeds the user with be authenticated against that session. When the user makes another call using the same session-cookie, then the `auth check` will detect the already authenticated session and skip the validator script. If you're using sessions and you don't want the `auth check` to check the session, or store the user against the session, then pass `-o @{ 'Session' = $false }` to the `auth check`. + +> Not defining session middleware is basically like always having `Session = $false` set on `auth check` + +#### Basic Auth + +> Example with comments in `examples/web-auth-basic.ps1` + +Basic authentication is when you pass a encoded username:password value on the header of your requests: `@{ 'Authorization' = 'Basic ' }`. To setup basic auth in Pode, you specify `auth use basic` in your server script; the validator script will have the username/password supplied as parameters: + +```powershell +Server { + auth use basic -v { + param($username, $password) + } +} +``` + +##### Options + +| Name | Description | +| ---- | ----------- | +| Encoding | Defines which encoding to use when decoding the auth header (def: `ISO-8859-1`) | +| Name | Defines the name part of the header, infront of the encoded sting (def: Basic) | + +#### Form Auth + +> Example with comments in `examples/web-auth-form.ps1` + +Form authentication is for when you're using a `
` in HTML, and you submit the form. The type expects a `username` and a `password` to be passed from the form input fields. To setup form auth in Pode, you specify `auth use form` in your server script; the validator script will have the username/password supplied as parameters: + +```powershell +Server { + auth use form -v { + param($username, $password) + } +} +``` + +```html + +
+ + +
+
+ + +
+
+ +
+
+``` + +##### Options + +| Name | Description | +| ---- | ----------- | +| UsernameField | Defines the name of field which the username will be passed in from the form (def: username) | +| PasswordField | Defines the name of field which the password will be passed in from the form (def: password) | + +#### Custom Auth + +Custom authentication works much like the above inbuilt types, but allows you to specify your own parsing logic. For example, let's say we wanted something similar to `form` authentication but it requires a third piece of information: ClientName. To setup a custom authentication, you can use any name and specify the `-c` flag; you'll also be required to specify the parsing scriptblock under `-p`: + +```powershell +Server { + auth use -c client -p { + # the current web-session (same data as supplied to routes), and options supplied + param($session, $opts) + + # get client/user/pass field names to get from payload + $clientField = (coalesce $opts.ClientField 'client') + $userField = (coalesce $opts.UsernameField 'username') + $passField = (coalesce $opts.PasswordField 'password') + + # get the client/user/pass from the post data + $client = $session.Data.$clientField + $username = $session.Data.$userField + $password = $session.Data.$passField + + # return the data, to be passed to the validator script + return @($client, $username, $password) + } ` + -v { + param($client, $username, $password) + + # find the user + # if not found, return null - for a 401 + + # return the user + return @{ 'user' = $user } + } + + route get '/users' (auth check client) { + param($session) + } +} +``` + ### SMTP Server Pode can also run as an SMTP server - useful for mocking tests. There are two options, you can either use Pode's inbuilt simple SMTP logic, or write your own using Pode as a TCP server instead. @@ -1051,13 +1263,6 @@ Pode comes with a few helper functions - mostly for writing responses and readin * `csv` * `view` * `tcp` -* `Get-PodeRoute` -* `Get-PodeTcpHandler` -* `Get-PodeTimer` -* `Write-ToResponse` -* `Write-ToResponseFromFile` -* `Test-IsUnix` -* `Test-IsPSCore` * `status` * `redirect` * `include` @@ -1070,4 +1275,7 @@ Pode comes with a few helper functions - mostly for writing responses and readin * `dispose` * `stream` * `schedule` -* `middleware` \ No newline at end of file +* `middleware` +* `endware` +* `session` +* `auth` \ No newline at end of file diff --git a/examples/external-funcs.ps1 b/examples/external-funcs.ps1 index 5c28f2e89..b6c8fae1e 100644 --- a/examples/external-funcs.ps1 +++ b/examples/external-funcs.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/file-monitoring.ps1 b/examples/file-monitoring.ps1 index 18ac9087b..b59e0c986 100644 --- a/examples/file-monitoring.ps1 +++ b/examples/file-monitoring.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/logging.ps1 b/examples/logging.ps1 index f37f32c18..e9a605f9a 100644 --- a/examples/logging.ps1 +++ b/examples/logging.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/loop-server.ps1 b/examples/loop-server.ps1 index d9c78fd61..a894fdc0d 100644 --- a/examples/loop-server.ps1 +++ b/examples/loop-server.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/mail-server.ps1 b/examples/mail-server.ps1 index 7f40b9883..a10145e02 100644 --- a/examples/mail-server.ps1 +++ b/examples/mail-server.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/middleware.ps1 b/examples/middleware.ps1 index d593dfbad..2cf17f6ee 100644 --- a/examples/middleware.ps1 +++ b/examples/middleware.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/nunit-rest-api.ps1 b/examples/nunit-rest-api.ps1 index 5281eb7f9..b200e1eae 100644 --- a/examples/nunit-rest-api.ps1 +++ b/examples/nunit-rest-api.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/rest-api.ps1 b/examples/rest-api.ps1 index bf8f29e9f..68e0237b9 100644 --- a/examples/rest-api.ps1 +++ b/examples/rest-api.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/schedules.ps1 b/examples/schedules.ps1 index 0d9b9a8f8..9c3df4e71 100644 --- a/examples/schedules.ps1 +++ b/examples/schedules.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/shared-state.ps1 b/examples/shared-state.ps1 index 1f10db0d1..3a8dd18c0 100644 --- a/examples/shared-state.ps1 +++ b/examples/shared-state.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/tcp-server.ps1 b/examples/tcp-server.ps1 index 92ff94d81..f58626d4f 100644 --- a/examples/tcp-server.ps1 +++ b/examples/tcp-server.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/timers.ps1 b/examples/timers.ps1 index 2135529fb..af23a6909 100644 --- a/examples/timers.ps1 +++ b/examples/timers.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/views/auth-home.pode b/examples/views/auth-home.pode new file mode 100644 index 000000000..46af21538 --- /dev/null +++ b/examples/views/auth-home.pode @@ -0,0 +1,17 @@ + + + Auth Home + + + + + Hello, $($data.Username)! You have view this page $($data.Views) times! + +
+
+ +
+
+ + + \ No newline at end of file diff --git a/examples/views/auth-login.pode b/examples/views/auth-login.pode new file mode 100644 index 000000000..87156c041 --- /dev/null +++ b/examples/views/auth-login.pode @@ -0,0 +1,25 @@ + + + Auth Login + + + + + Please Login: + +
+
+ + +
+
+ + +
+
+ +
+
+ + + \ No newline at end of file diff --git a/examples/web-auth-basic.ps1 b/examples/web-auth-basic.ps1 new file mode 100644 index 000000000..7b4ee73df --- /dev/null +++ b/examples/web-auth-basic.ps1 @@ -0,0 +1,53 @@ +$path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop + +# or just: +# Import-Module Pode + +<# +This example shows how to use sessionless authentication, which will mostly be for +REST APIs. The example used here is Basic authentication. + +Calling the '[POST] http://localhost:8085/users' endpoint, with an Authorization +header of 'Basic bW9ydHk6cGlja2xl' will display the uesrs. Anything else and +you'll get a 401 status code back. +#> + +# create a server, and start listening on port 8085 +Server -Threads 2 { + + # listen on localhost:8085 + listen *:8085 http + + # setup basic auth (base64> username:password in header) + auth use basic -v { + param($username, $password) + + # here you'd check a real user storage, this is just for example + if ($username -eq 'morty' -and $password -eq 'pickle') { + return @{ 'user' = @{ + 'ID' ='M0R7Y302' + 'Name' = 'Morty'; + 'Type' = 'Human'; + } } + } + + return $null + } + + # POST request to get list of users (since there's no session, the auth check will always happen) + route 'post' '/users' (auth check basic) { + param($s) + json @{ 'Users' = @( + @{ + 'Name' = 'Deep Thought'; + 'Age' = 42; + }, + @{ + 'Name' = 'Leeroy Jenkins'; + 'Age' = 1337; + } + ) } + } + +} -FileMonitor \ No newline at end of file diff --git a/examples/web-auth-form.ps1 b/examples/web-auth-form.ps1 new file mode 100644 index 000000000..a232b7506 --- /dev/null +++ b/examples/web-auth-form.ps1 @@ -0,0 +1,87 @@ +$path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop + +# or just: +# Import-Module Pode + +<# +This examples shows how to use session persistant authentication, for things like logins on websites. +The example used here is Form authentication, sent from the
in HTML. + +Navigating to the 'http://localhost:8085' endpoint in your browser will auto-rediect you to the '/login' +page. Here, you can type the username (morty) and the password (pickle); clicking 'Login' will take you +back to the home page with a greeting and a view counter. Clicking 'Logout' will purge the session and +take you back to the login page. +#> + +# create a server, and start listening on port 8085 +Server -Threads 2 { + + # listen on localhost:8085 + listen *:8085 http + + # set the view engine + engine pode + + # setup session details + middleware (session @{ + 'Secret' = 'schwifty'; + 'Duration' = 120; + 'Extend' = $true; + }) + + # setup form auth ( in HTML) + auth use form -v { + param($username, $password) + + # here you'd check a real user storage, this is just for example + if ($username -eq 'morty' -and $password -eq 'pickle') { + return @{ 'user' = @{ + 'ID' ='M0R7Y302' + 'Name' = 'Morty'; + 'Type' = 'Human'; + } } + } + + return $null + } + + # home page: + # redirects to login page if not authenticated + route 'get' '/' (auth check form -o @{ 'failureUrl' = '/login' }) { + param($s) + + $s.Session.Data.Views++ + + view 'auth-home' -data @{ + 'Username' = $s.Auth.User.Name; + 'Views' = $s.Session.Data.Views; + } + } + + # login page: + # the login flag set below checks if there is already an authenticated session cookie. If there is, then + # the user is redirected to the home page. If there is no session then the login page will load without + # checking user authetication (to prevent a 401 status) + route 'get' '/login' (auth check form -o @{ 'login' = $true; 'successUrl' = '/' }) { + param($s) + view 'auth-login' + } + + # login check: + # this is the endpoint the 's action will invoke. If the user validates then they are set against + # the session as authenticated, and redirect to the home page. If they fail, then the login page reloads + route 'post' '/login' (auth check form -o @{ + 'failureUrl' = '/login'; + 'successUrl' = '/'; + }) {} + + # logout check: + # when the logout button is click, this endpoint is invoked. The logout flag set below informs this call + # to purge the currently authenticated session, and then redirect back to the login page + route 'post' '/logout' (auth check form -o @{ + 'logout' = $true; + 'failureUrl' = '/login'; + }) {} + +} \ No newline at end of file diff --git a/examples/web-pages-docker.ps1 b/examples/web-pages-docker.ps1 index 722bc4ee8..9fba2f712 100644 --- a/examples/web-pages-docker.ps1 +++ b/examples/web-pages-docker.ps1 @@ -1,9 +1,4 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - -Import-Module Pode +Import-Module Pode -Force # create a server, and start listening on port 8085 Server -Threads 2 { diff --git a/examples/web-pages-https.ps1 b/examples/web-pages-https.ps1 index ac7578988..97057a2fb 100644 --- a/examples/web-pages-https.ps1 +++ b/examples/web-pages-https.ps1 @@ -1,10 +1,5 @@ -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/web-pages.ps1 b/examples/web-pages.ps1 index a4a2c3ba5..bea09e165 100644 --- a/examples/web-pages.ps1 +++ b/examples/web-pages.ps1 @@ -3,13 +3,8 @@ param ( $Port = 8085 ) -if ((Get-Module -Name Pode | Measure-Object).Count -ne 0) -{ - Remove-Module -Name Pode -} - $path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) -Import-Module "$($path)/src/Pode.psm1" -ErrorAction Stop +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop # or just: # Import-Module Pode diff --git a/examples/web-sessions.ps1 b/examples/web-sessions.ps1 new file mode 100644 index 000000000..acbf6f94a --- /dev/null +++ b/examples/web-sessions.ps1 @@ -0,0 +1,34 @@ +$path = Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) +Import-Module "$($path)/src/Pode.psm1" -Force -ErrorAction Stop + +# or just: +# Import-Module Pode + +# create a server, and start listening on port 8085 +Server { + + # listen on localhost:8085 + listen *:8085 http + + # set view engine to pode renderer + engine pode + + # setup session details + middleware (session @{ + 'Secret' = 'schwifty'; # secret-key used to sign session cookie + 'Name' = 'pode.sid'; # session cookie name (def: pode.sid) + 'Duration' = 120; # duration of the cookie, in seconds + 'Extend' = $true; # extend the duration of the cookie on each call + 'GenerateId' = { # custom SessionId generator (def: guid) + return [System.IO.Path]::GetRandomFileName() + }; + }) + + # GET request for web page on "localhost:8085/" + route 'get' '/' { + param($s) + $s.Session.Data.Views++ + view 'simple' -Data @{ 'numbers' = @($s.Session.Data.Views); } + } + +} \ No newline at end of file diff --git a/packers/choco/pode.nuspec b/packers/choco/pode.nuspec index 8825ea133..73c8a9bd6 100644 --- a/packers/choco/pode.nuspec +++ b/packers/choco/pode.nuspec @@ -29,13 +29,15 @@ Pode is a Cross-Platform PowerShell framework that allows you to host REST APIs, * Basic rate limiting for IP addresses and subnets * Support for generating/binding self-signed certificates, and binding signed certificates * Support for middleware on web servers +* Session middleware support on web requests +* Can use authentication on requests, which can either be sessionless or session persistant https://github.com/Badgerati/Pode https://github.com/Badgerati/Pode/tree/master/packers https://github.com/Badgerati/Pode https://github.com/Badgerati/Pode/issues - pode powershell web server rest api http tcp smtp listener webpages json xml html unix cross-platform access-control file-monitoring multithreaded rate-limiting cron schedule middleware + pode powershell web server rest api http tcp smtp listener webpages json xml html unix cross-platform access-control file-monitoring multithreaded rate-limiting cron schedule middleware session authentication Copyright 2017-2018 https://github.com/Badgerati/Pode/blob/master/LICENSE.txt false diff --git a/src/Pode.psd1 b/src/Pode.psd1 index 95c3bf9b0..3117a2ec4 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -65,7 +65,11 @@ 'Dispose', 'Stream', 'Schedule', - 'Middleware' + 'Middleware', + 'Endware', + 'Session', + 'Invoke-ScriptBlock', + 'Auth' ) # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. @@ -75,7 +79,7 @@ # Tags applied to this module. These help with module discovery in online galleries. Tags = @('powershell', 'web', 'server', 'http', 'listener', 'rest', 'api', 'tcp', 'smtp', 'websites', 'powershell-core', 'windows', 'unix', 'linux', 'pode', 'PSEdition_Core', 'cross-platform', 'access-control', - 'file-monitoring', 'multithreaded', 'rate-limiting', 'cron', 'schedule', 'middleware') + 'file-monitoring', 'multithreaded', 'rate-limiting', 'cron', 'schedule', 'middleware', 'session', 'authentication') # A URL to the license for this module. LicenseUri = 'https://raw.githubusercontent.com/Badgerati/Pode/master/LICENSE.txt' diff --git a/src/Tools/Authentication.ps1 b/src/Tools/Authentication.ps1 new file mode 100644 index 000000000..1569e041a --- /dev/null +++ b/src/Tools/Authentication.ps1 @@ -0,0 +1,438 @@ +function Auth +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateSet('use', 'check')] + [Alias('a')] + [string] + $Action, + + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [Alias('n')] + [string] + $Name, + + [Parameter()] + [Alias('v')] + [scriptblock] + $Validator, + + [Parameter()] + [Alias('p')] + [scriptblock] + $Parser, + + [Parameter()] + [Alias('o')] + [hashtable] + $Options, + + [switch] + [Alias('c')] + $Custom + ) + + if ($Action -ieq 'use') { + if (Test-Empty $Validator) { + throw "Authentication method '$($Name)' is missing required Validator script" + } + + if ($Custom -and (Test-Empty $Parser)) { + throw "Custom authentication method '$($Name)' is missing required Parser script" + } + } + + switch ($Action.ToLowerInvariant()) + { + 'use' { + Invoke-AuthUse -Name $Name -Validator $Validator -Parser $Parser -Options $Options + } + + 'check' { + return (Invoke-AuthCheck -Name $Name -Options $Options) + } + } +} + +function Invoke-AuthUse +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string] + $Name, + + [Parameter(Mandatory=$true)] + [scriptblock] + $Validator, + + [Parameter()] + [scriptblock] + $Parser, + + [Parameter()] + [hashtable] + $Options, + + [switch] + $Custom + ) + + # get the auth data + $AuthData = (Get-PodeAuthMethod -Name $Name -Validator $Validator -Parser $Parser -Custom:$Custom) + + # ensure the name doesn't already exist + if ($PodeSession.Server.Authentications.ContainsKey($AuthData.Name)) { + throw "Authentication method '$($AuthData.Name)' already defined" + } + + # ensure the parser/validators aren't just empty scriptblocks + if (Test-Empty $AuthData.Parser) { + throw "Authentication method '$($AuthData.Name)' is has no Parser ScriptBlock logic defined" + } + + if (Test-Empty $AuthData.Validator) { + throw "Authentication method '$($AuthData.Name)' is has no Validator ScriptBlock logic defined" + } + + # setup object for auth method + $obj = @{ + 'Options' = $Options; + 'Parser' = $AuthData.Parser; + 'Validator' = $AuthData.Validator; + 'Custom' = $AuthData.Custom; + } + + # apply auth method to session + $PodeSession.Server.Authentications[$AuthData.Name] = $obj +} + +function Invoke-AuthCheck +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string] + $Name, + + [Parameter()] + [hashtable] + $Options + ) + + # ensure the auth type exists + if (!$PodeSession.Server.Authentications.ContainsKey($Name)) { + throw "Authentication method '$($Name)' is not defined" + } + + # coalesce the options, and set auth type for middleware + $Options = (coalesce $Options @{}) + $Options.AuthType = $Name + + # setup the middleware logic + $logic = { + param($s) + + # Route options for using sessions + $storeInSession = ($s.Middleware.Options.Session -ne $false) + $usingSessions = (!(Test-Empty $s.Session)) + + # check for logout command + if ($s.Middleware.Options.Logout -eq $true) { + Remove-PodeAuth -Session $s + return (Set-PodeAuthStatus -StatusCode 302 -Options $s.Middleware.Options) + } + + # if the session already has a user/isAuth'd, then setup method and return + if ($usingSessions -and !(Test-Empty $s.Session.Data.Auth.User) -and $s.Session.Data.Auth.IsAuthenticated) { + $s.Auth = $s.Session.Data.Auth + return (Set-PodeAuthStatus -Options $s.Middleware.Options) + } + + # check if the login flag is set, in which case just return + if ($s.Middleware.Options.Login -eq $true) { + Remove-PodeSessionCookie -Response $s.Response -Session $s.Session + return $true + } + + # get the auth type + $auth = $PodeSession.Server.Authentications[$s.Middleware.Options.AuthType] + + # validate the request and get a user + try { + # if it's a custom type the parser will return the dat for use to pass to the validator + if ($auth.Custom) { + $data = (Invoke-ScriptBlock -ScriptBlock $auth.Parser -Arguments @($s, $auth.Options) -Return -Splat) + $result = (Invoke-ScriptBlock -ScriptBlock $auth.Validator -Arguments $data -Return -Splat) + } + else { + $result = (Invoke-ScriptBlock -ScriptBlock $auth.Parser -Arguments @($s, $auth) -Return -Splat) + } + } + catch { + $_.Exception | Out-Default + return (Set-PodeAuthStatus -StatusCode 500 -Options $s.Middleware.Options) + } + + # if there is no result return false (failed auth) + if ((Test-Empty $result) -or (Test-Empty $result.User)) { + return (Set-PodeAuthStatus -StatusCode (coalesce $result.Code 401) ` + -Description $result.Message -Options $s.Middleware.Options) + } + + # assign the user to the session, and wire up a quick method + $s.Auth = @{} + $s.Auth.User = $result.User + $s.Auth.IsAuthenticated = $true + $s.Auth.Store = $storeInSession + + # continue + return (Set-PodeAuthStatus -Options $s.Middleware.Options) + } + + # return the middleware + return @{ + 'Logic' = $logic; + 'Options' = $Options; + } +} + +function Get-PodeAuthMethod +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string] + $Name, + + [Parameter(Mandatory=$true)] + [scriptblock] + $Validator, + + [Parameter()] + [scriptblock] + $Parser, + + [switch] + $Custom + ) + + # first, is it just a custom type? + if ($Custom) { + return @{ + 'Name' = $Name; + 'Custom' = $true; + 'Parser' = $Parser; + 'Validator' = $Validator; + } + } + + # otherwise, check the inbuilt ones + switch ($Name.ToLowerInvariant()) + { + 'basic' { + return (Get-PodeAuthBasic -ScriptBlock $Validator) + } + + 'form' { + return (Get-PodeAuthForm -ScriptBlock $Validator) + } + } + + # if we get here, check if a parser was passed for custom type + if (Test-Empty $Parser) { + throw "Authentication method '$($Name)' does not exist as an inbuilt type, nor has a Parser been passed for a custom type" + } + + # a parser was passed, so it is a custom type + return @{ + 'Name' = $Name; + 'Custom' = $true; + 'Parser' = $Parser; + 'Validator' = $Validator; + } +} + +function Remove-PodeAuth +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Session + ) + + # blank out the auth + $Session.Auth = @{} + + # if a session auth is found, blank it + if (!(Test-Empty $Session.Session.Data.Auth)) { + $Session.Session.Data.Remove('Auth') + } + + # redirect to a failure url, or onto the current path? + if (Test-Empty $Session.Middleware.Options.FailureUrl) { + $Session.Middleware.Options.FailureUrl = $Session.Request.Url.AbsolutePath + } + + # Delete the session (remove from store, blank it, and remove from Response) + Remove-PodeSessionCookie -Response $Session.Response -Session $Session.Session +} + +function Set-PodeAuthStatus +{ + param ( + [Parameter()] + [int] + $StatusCode = 0, + + [Parameter()] + [string] + $Description, + + [Parameter()] + [hashtable] + $Options + ) + + # if a statuscode supplied, assume failure + if ($StatusCode -gt 0) + { + # check if we have a failure url redirect + if (!(Test-Empty $Options.FailureUrl)) { + redirect $Options.FailureUrl + } + else { + status $StatusCode $Description + } + + return $false + } + + # if no statuscode, success + else + { + # check if we have a success url redirect + if (!(Test-Empty $Options.SuccessUrl)) { + redirect $Options.SuccessUrl + return $false + } + + return $true + } +} + +function Get-PodeAuthBasic +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + [scriptblock] + $ScriptBlock + ) + + $parser = { + param($s, $auth) + + # get the auth header + $header = $s.Request.Headers['Authorization'] + if ($null -eq $header) { + return @{ + 'User' = $null; + 'Message' = 'No Authorization header found'; + 'Code' = 401; + } + } + + # ensure the first atom is basic (or opt override) + $atoms = $header -isplit '\s+' + $authType = (coalesce $auth.Options.Name 'Basic') + + if ($atoms[0] -ine $authType) { + return @{ + 'User' = $null; + 'Message' = "Header is not $($authType) Authorization"; + } + } + + # decode the aut header + $encType = (coalesce $auth.Options.Encoding 'ISO-8859-1') + + try { + $enc = [System.Text.Encoding]::GetEncoding($encType) + } + catch { + return @{ + 'User' = $null; + 'Message' = 'Invalid encoding specified for Authorization'; + 'Code' = 400; + } + } + + try { + $decoded = $enc.GetString([System.Convert]::FromBase64String($atoms[1])) + } + catch { + return @{ + 'User' = $null; + 'Message' = 'Invalid Base64 string found in Authorization header'; + 'Code' = 400; + } + } + + # validate and return user/result + $index = $decoded.IndexOf(':') + $u = $decoded.Substring(0, $index) + $p = $decoded.Substring($index + 1) + + return (Invoke-ScriptBlock -ScriptBlock $auth.Validator -Arguments @($u, $p) -Return -Splat) + } + + return @{ + 'Name' = 'Basic'; + 'Custom' = $false; + 'Parser' = $parser; + 'Validator' = $ScriptBlock; + } +} + +function Get-PodeAuthForm +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + [scriptblock] + $ScriptBlock + ) + + $parser = { + param($s, $auth) + + # get user/pass keys to get from payload + $userField = (coalesce $auth.Options.UsernameField 'username') + $passField = (coalesce $auth.Options.PasswordField 'password') + + # get the user/pass + $username = $s.Data.$userField + $password = $s.Data.$passField + + # if either are empty, deny + if ((Test-Empty $username) -or (Test-Empty $password)) { + return @{ + 'User' = $null; + 'Message' = 'Username or Password not supplied'; + 'Code' = 401; + } + } + + # validate and return + return (Invoke-ScriptBlock -ScriptBlock $auth.Validator -Arguments @($username, $password) -Return -Splat) + } + + return @{ + 'Name' = 'Form'; + 'Custom' = $false; + 'Parser' = $parser; + 'Validator' = $ScriptBlock; + } +} \ No newline at end of file diff --git a/src/Tools/Cookies.ps1 b/src/Tools/Cookies.ps1 new file mode 100644 index 000000000..327177687 --- /dev/null +++ b/src/Tools/Cookies.ps1 @@ -0,0 +1,364 @@ +function Session +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + [hashtable] + $Options + ) + + # check that session logic hasn't already been defined + if (!(Test-Empty $PodeSession.Server.Cookies.Session)) { + throw 'Session middleware logic has already been defined' + } + + # ensure a secret was actually passed + if (Test-Empty $Options.Secret) { + throw 'A secret key is required for session cookies' + } + + # ensure the override generator is a scriptblock + if (!(Test-Empty $Options.GenerateId) -and (Get-Type $Options.GenerateId).Name -ine 'scriptblock') { + throw "Session GenerateId should be a ScriptBlock, but got: $((Get-Type $Options.GenerateId).Name)" + } + + # ensure the override store has the required methods + if (!(Test-Empty $Options.Store)) { + $members = @($Options.Store | Get-Member | Select-Object -ExpandProperty Name) + @('delete', 'get', 'set') | ForEach-Object { + if ($members -inotcontains $_) { + throw "Custom session store does not implement the required '$($_)' method" + } + } + } + + # ensure the duration is not <0 + $Options.Duration = [int]($Options.Duration) + if ($Options.Duration -lt 0) { + throw "Session duration must be 0 or greater, but got: $($Options.Duration)s" + } + + # get the appropriate store + $store = $Options.Store + + # if no custom store, use the inmem one + if (Test-Empty $store) { + $store = (Get-PodeSessionCookieInMemStore) + Set-PodeSessionCookieInMemClearDown + } + + # set options against session + $PodeSession.Server.Cookies.Session = @{ + 'Name' = (coalesce $Options.Name 'pode.sid'); + 'SecretKey' = $Options.Secret; + 'GenerateId' = (coalesce $Options.GenerateId { return (Get-NewGuid) }); + 'Store' = $store; + 'Info' = @{ + 'Duration' = [int]($Options.Duration); + 'Extend' = [bool]($Options.Extend); + 'Secure' = [bool]($Options.Secure); + 'Discard' = [bool]($Options.Discard); + }; + } + + # bind session middleware to attach session function + return { + param($s) + + # if session already set, return + if ($s.Session) { + return $true + } + + try + { + # get the session cookie + $s.Session = Get-PodeSessionCookie -Request $s.Request + + # if no session on browser, create a new one + if (!$s.Session) { + $s.Session = (New-PodeSessionCookie) + $new = $true + } + + # get the session's data + elseif ($null -ne ($data = $PodeSession.Server.Cookies.Session.Store.Get($s.Session.Id))) { + $s.Session.Data = $data + Set-PodeSessionCookieDataHash -Session $s.Session + } + + # session not in store, create a new one + else { + $s.Session = (New-PodeSessionCookie) + $new = $true + } + + # add helper methods to session + Set-PodeSessionCookieHelpers -Session $s.Session + + # add cookie to response if it's new or extendible + if ($new -or $s.Session.Cookie.Extend) { + Set-PodeSessionCookie -Response $s.Response -Session $s.Session + } + + # assign endware for session to set cookie/storage + $s.OnEnd += { + param($s) + + # if auth is in use, then assign to session store + if (!(Test-Empty $s.Auth) -and $s.Auth.Store) { + $s.Session.Data.Auth = $s.Auth + } + + Invoke-ScriptBlock -ScriptBlock $s.Session.Save -Arguments @($s.Session, $true) -Splat + } + } + catch { + $Error[0] | Out-Default + return $false + } + + # move along + return $true + } +} + +function Get-PodeSessionCookie +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Request + ) + + # get the session from cookie + $cookie = $Request.Cookies[$PodeSession.Server.Cookies.Session.Name] + if ((Test-Empty $cookie) -or (Test-Empty $cookie.Value)) { + return $null + } + + # ensure the session was signed + $session = (Invoke-CookieUnsign -Signature $cookie.Value -Secret $PodeSession.Server.Cookies.Session.SecretKey) + if (Test-Empty $session) { + return $null + } + + # return session cookie data + $data = @{ + 'Name' = $cookie.Name; + 'Id' = $session; + 'Cookie' = $PodeSession.Server.Cookies.Session.Info; + 'Data' = @{}; + } + + $data.Cookie.TimeStamp = $cookie.TimeStamp + return $data +} + +function Set-PodeSessionCookie +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Response, + + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Session + ) + + # sign the session + $signedValue = (Invoke-CookieSign -Value $Session.Id -Secret $PodeSession.Server.Cookies.Session.SecretKey) + + # create a new cookie + $cookie = [System.Net.Cookie]::new($Session.Name, $signedValue) + $cookie.Secure = $Session.Cookie.Secure + $cookie.Discard = $Session.Cookie.Discard + + # calculate the expiry + $cookie.Expires = (Get-PodeSessionCookieExpiry -Session $Session) + + # assign cookie to response + $Response.AppendCookie($cookie) | Out-Null +} + +function Remove-PodeSessionCookie +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Response, + + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Session + ) + + # remove the cookie from the response, and reset it to expire + $cookie = $Response.Cookies[$Session.Name] + $cookie.Discard = $true + $cookie.Expires = [DateTime]::UtcNow.AddDays(-2) + $Response.AppendCookie($cookie) | Out-Null + + # remove session from store + Invoke-ScriptBlock -ScriptBlock $Session.Delete -Arguments @($Session) -Splat + + # blank the session + $Session.Clear() +} + +function New-PodeSessionCookie +{ + $sid = @{ + 'Name' = $PodeSession.Server.Cookies.Session.Name; + 'Id' = (Invoke-ScriptBlock -ScriptBlock $PodeSession.Server.Cookies.Session.GenerateId -Return); + 'Cookie' = $PodeSession.Server.Cookies.Session.Info; + 'Data' = @{}; + } + + Set-PodeSessionCookieDataHash -Session $sid + + $sid.Cookie.TimeStamp = [DateTime]::UtcNow + return $sid +} + +function Set-PodeSessionCookieDataHash +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Session + ) + + $Session.Data = (coalesce $Session.Data @{}) + $Session.DataHash = (Invoke-SHA256Hash -Value ($Session.Data | ConvertTo-Json)) +} + +function Test-PodeSessionCookieDataHash +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Session + ) + + if (Test-Empty $Session.DataHash) { + return $false + } + + $Session.Data = (coalesce $Session.Data @{}) + $hash = (Invoke-SHA256Hash -Value ($Session.Data | ConvertTo-Json)) + return ($Session.DataHash -eq $hash) +} + +function Get-PodeSessionCookieExpiry +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Session + ) + + $expiry = (iftet $Session.Cookie.Extend ([DateTime]::UtcNow) $Session.Cookie.TimeStamp) + $expiry = $expiry.AddSeconds($Session.Cookie.Duration) + return $expiry +} + +function Set-PodeSessionCookieHelpers +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Session + ) + + # force save a session's data to the store + $Session | Add-Member -MemberType NoteProperty -Name Save -Value { + param($session, $check) + + # only save if check and hashes different + if ($check -and (Test-PodeSessionCookieDataHash -Session $session)) { + return + } + + # generate the expiry + $expiry = (Get-PodeSessionCookieExpiry -Session $session) + + # save session data to store + $PodeSession.Server.Cookies.Session.Store.Set($session.Id, $session.Data, $expiry) + + # update session's data hash + Set-PodeSessionCookieDataHash -Session $session + } + + # delete the current session + $Session | Add-Member -MemberType NoteProperty -Name Delete -Value { + param($session) + + # remove data from store + $PodeSession.Server.Cookies.Session.Store.Delete($session.Id) + + # clear session + $session.Clear() + } +} + +function Get-PodeSessionCookieInMemStore +{ + $store = New-Object -TypeName psobject + + # add in-mem storage + $store | Add-Member -MemberType NoteProperty -Name Memory -Value @{} + + # delete a sessionId and data + $store | Add-Member -MemberType ScriptMethod -Name Delete -Value { + param($sessionId) + $this.Memory.Remove($sessionId) | Out-Null + } + + # get a sessionId's data + $store | Add-Member -MemberType ScriptMethod -Name Get -Value { + param($sessionId) + + $s = $this.Memory[$sessionId] + + # if expire, remove + if ($null -ne $s -and $s.Expiry -lt [DateTime]::UtcNow) { + $this.Memory.Remove($sessionId) | Out-Null + return $null + } + + return $s.Data + } + + # update/insert a sessionId and data + $store | Add-Member -MemberType ScriptMethod -Name Set -Value { + param($sessionId, $data, $expiry) + + $this.Memory[$sessionId] = @{ + 'Data' = $data; + 'Expiry' = $expiry; + } + } + + return $store +} + +function Set-PodeSessionCookieInMemClearDown +{ + # cleardown expired inmem session every 10 minutes + schedule '__pode_session_inmem_cleanup__' '0/10 * * * *' { + $store = $PodeSession.Server.Cookies.Session.Store + if (Test-Empty $store.Memory) { + return + } + + # remove sessions that have expired + $now = [DateTime]::UtcNow + $store.Memory.Keys | ForEach-Object { + if ($store.Memory[$_].Expiry -lt $now) { + $store.Memory.Remove($_) + } + } + } +} \ No newline at end of file diff --git a/src/Tools/Cryptography.ps1 b/src/Tools/Cryptography.ps1 new file mode 100644 index 000000000..60fa95f2d --- /dev/null +++ b/src/Tools/Cryptography.ps1 @@ -0,0 +1,77 @@ +function Invoke-HMACSHA256Hash +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string] + $Value, + + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string] + $Secret + ) + + $crypto = [System.Security.Cryptography.HMACSHA256]::new([System.Text.Encoding]::UTF8.GetBytes($Secret)) + return [System.Convert]::ToBase64String($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))) +} + +function Invoke-SHA256Hash +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string] + $Value + ) + + $crypto = [System.Security.Cryptography.SHA256]::Create() + return [System.Convert]::ToBase64String($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))) +} + +function Invoke-CookieSign +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string] + $Value, + + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string] + $Secret + ) + + return "s:$($Value).$(Invoke-HMACSHA256Hash -Value $Value -Secret $Secret)" +} + +function Invoke-CookieUnsign +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string] + $Signature, + + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string] + $Secret + ) + + if (!$Signature.StartsWith('s:')) { + return $null + } + + $Signature = $Signature.Substring(2) + $periodIndex = $Signature.LastIndexOf('.') + $value = $Signature.Substring(0, $periodIndex) + $sig = $Signature.Substring($periodIndex + 1) + + if ((Invoke-HMACSHA256Hash -Value $value -Secret $Secret) -ne $sig) { + return $null + } + + return $value +} \ No newline at end of file diff --git a/src/Tools/Endware.ps1 b/src/Tools/Endware.ps1 new file mode 100644 index 000000000..9217cba1e --- /dev/null +++ b/src/Tools/Endware.ps1 @@ -0,0 +1,48 @@ +function Invoke-PodeEndware +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + $Session, + + [Parameter()] + $Endware + ) + + # if there's no endware, do nothing + if (Test-Empty $Endware) { + return $true + } + + # continue or halt? + $continue = $true + + # loop through each of the endware, invoking the next if it returns true + foreach ($eware in @($Endware)) + { + try { + $continue = Invoke-ScriptBlock -ScriptBlock $eware -Arguments $Session -Scoped -Return + } + catch { + $Error[0] | Out-Default + $continue = $false + } + + if (!$continue) { + break + } + } +} + +function Endware +{ + param ( + [Parameter(Mandatory=$true)] + [ValidateNotNull()] + [scriptblock] + $ScriptBlock + ) + + # add the scriptblock to array of endware that needs to be run + $PodeSession.Server.Endware += $ScriptBlock +} \ No newline at end of file diff --git a/src/Tools/Helpers.ps1 b/src/Tools/Helpers.ps1 index 8a201d834..6875e2213 100644 --- a/src/Tools/Helpers.ps1 +++ b/src/Tools/Helpers.ps1 @@ -20,7 +20,7 @@ function ConvertFrom-PodeFile } # invoke the content as a script to generate the dynamic content - return (Invoke-ScriptBlock -ScriptBlock ([scriptblock]::Create($Content)) -Arguments $Data) + return (Invoke-ScriptBlock -ScriptBlock ([scriptblock]::Create($Content)) -Arguments $Data -Return) } function Get-Type @@ -53,12 +53,18 @@ function Test-Empty return $true } - if ($type.Name -ieq 'string') { - return [string]::IsNullOrWhiteSpace($Value) - } + switch ($type.Name) { + 'string' { + return [string]::IsNullOrWhiteSpace($Value) + } + + 'hashtable' { + return ($Value.Count -eq 0) + } - if ($type.Name -ieq 'hashtable') { - return $Value.Count -eq 0 + 'scriptblock' { + return ($null -eq $Value -or [string]::IsNullOrWhiteSpace($Value.ToString())) + } } switch ($type.BaseName) { @@ -432,21 +438,46 @@ function Close-PodeRunspaces } } +function Get-ConsoleKey +{ + if ([Console]::IsInputRedirected -or ![Console]::KeyAvailable) { + return $null + } + + return [Console]::ReadKey($true) +} + function Test-TerminationPressed { - if ($PodeSession.DisableTermination -or [Console]::IsInputRedirected -or ![Console]::KeyAvailable) { + param ( + [Parameter()] + $Key = $null + ) + + if ($PodeSession.DisableTermination) { return $false } - $key = [Console]::ReadKey($true) - - if ($key.Key -ieq 'c' -and $key.Modifiers -band [ConsoleModifiers]::Control) { - return $true + if ($null -eq $Key) { + $Key = Get-ConsoleKey } - return $false + return ($null -ne $Key -and $Key.Key -ieq 'c' -and $Key.Modifiers -band [ConsoleModifiers]::Control) } +function Test-RestartPressed +{ + param ( + [Parameter()] + $Key = $null + ) + + if ($null -eq $Key) { + $Key = Get-ConsoleKey + } + + return ($null -ne $Key -and $Key.Key -ieq 'r' -and $Key.Modifiers -band [ConsoleModifiers]::Control) +} function Start-TerminationListener { @@ -539,7 +570,7 @@ function Lock $locked = $true if ($ScriptBlock -ne $null) { - Invoke-ScriptBlock -ScriptBlock $ScriptBlock + Invoke-ScriptBlock -ScriptBlock $ScriptBlock -NoNewClosure } } catch { @@ -588,32 +619,45 @@ function Invoke-ScriptBlock $ScriptBlock, [Parameter()] - [object] $Arguments = $null, [switch] $Scoped, [switch] - $Return + $Return, + + [switch] + $Splat, + + [switch] + $NoNewClosure ) + if (!$NoNewClosure) { + $ScriptBlock = ($ScriptBlock).GetNewClosure() + } + if ($Scoped) { - if ($Return) { - return (& $ScriptBlock $Arguments) + if ($Splat) { + $result = (& $ScriptBlock @Arguments) } else { - & $ScriptBlock $Arguments + $result = (& $ScriptBlock $Arguments) } } else { - if ($Return) { - return (. $ScriptBlock $Arguments) + if ($Splat) { + $result = (. $ScriptBlock @Arguments) } else { - . $ScriptBlock $Arguments + $result = (. $ScriptBlock $Arguments) } } + + if ($Return) { + return $result + } } <# @@ -640,6 +684,19 @@ function Iftet return $Value2 } +function Coalesce +{ + param ( + [Parameter()] + $Value1, + + [Parameter()] + $Value2 + ) + + return (iftet (Test-Empty $Value1) $Value2 $Value1) +} + function Get-FileExtension { param ( @@ -696,7 +753,7 @@ function Stream ) try { - return (Invoke-ScriptBlock -ScriptBlock $ScriptBlock -Arguments $InputObject) + return (Invoke-ScriptBlock -ScriptBlock $ScriptBlock -Arguments $InputObject -Return -NoNewClosure) } catch { $Error[0] | Out-Default @@ -814,7 +871,35 @@ function ConvertFrom-PodeContent { $_ -ilike '*/csv' } { $Content = ($Content | ConvertFrom-Csv) } + + { $_ -ilike '*/x-www-form-urlencoded' } { + $Content = (ConvertFrom-NameValueToHashTable -Collection ([System.Web.HttpUtility]::ParseQueryString($Content))) + } } return $Content +} + +function ConvertFrom-NameValueToHashTable +{ + param ( + [Parameter()] + $Collection + ) + + if ($null -eq $Collection) { + return $null + } + + $ht = @{} + $Collection.Keys | ForEach-Object { + $ht[$_] = $Collection[$_] + } + + return $ht +} + +function Get-NewGuid +{ + return ([guid]::NewGuid()).ToString() } \ No newline at end of file diff --git a/src/Tools/Logging.ps1 b/src/Tools/Logging.ps1 index 5b9f7cb88..f5dab7b02 100644 --- a/src/Tools/Logging.ps1 +++ b/src/Tools/Logging.ps1 @@ -36,7 +36,7 @@ function New-PodeLogObject }; 'Response' = @{ 'StatusCode' = '-'; - 'StautsDescription' = '-'; + 'StatusDescription' = '-'; 'Size' = '-'; }; } diff --git a/src/Tools/Middleware.ps1 b/src/Tools/Middleware.ps1 index e41515c10..dfe2f160f 100644 --- a/src/Tools/Middleware.ps1 +++ b/src/Tools/Middleware.ps1 @@ -20,8 +20,20 @@ function Invoke-PodeMiddleware # loop through each of the middleware, invoking the next if it returns true foreach ($midware in @($Middleware)) { - $continue = Invoke-ScriptBlock -ScriptBlock ($midware.GetNewClosure()) ` - -Arguments $Session -Scoped -Return + try { + # set any custom middleware options + $Session.Middleware = @{ 'Options' = $midware.Options } + + # invoke the middleware logic + $continue = Invoke-ScriptBlock -ScriptBlock $midware.Logic -Arguments $Session -Scoped -Return + + # remove any custom middleware options + $Session.Middleware.Clear() + } + catch { + $_.Exception | Out-Default + $continue = $false + } if (!$continue) { break @@ -176,7 +188,7 @@ function Get-PodeQueryMiddleware try { # set the query string from the request - $s.Query = $s.Request.QueryString + $s.Query = (ConvertFrom-NameValueToHashTable -Collection $s.Request.QueryString) return $true } catch [exception] diff --git a/src/Tools/Responses.ps1 b/src/Tools/Responses.ps1 index 787793554..2d6eaaebe 100644 --- a/src/Tools/Responses.ps1 +++ b/src/Tools/Responses.ps1 @@ -86,7 +86,7 @@ function Write-ToResponseFromFile default { if ($null -ne $PodeSession.Server.ViewEngine.Script) { - $content = (Invoke-ScriptBlock -ScriptBlock $PodeSession.Server.ViewEngine.Script -Arguments $Path) + $content = (Invoke-ScriptBlock -ScriptBlock $PodeSession.Server.ViewEngine.Script -Arguments $Path -Return) } } } @@ -149,7 +149,10 @@ function Status ) $WebSession.Response.StatusCode = $Code - $WebSession.Response.StatusDescription = $Description + + if (!(Test-Empty $Description)) { + $WebSession.Response.StatusDescription = $Description + } } function Redirect @@ -372,9 +375,11 @@ function View param ( [Parameter(Mandatory=$true)] [ValidateNotNull()] + [Alias('p')] $Path, [Parameter()] + [Alias('d')] $Data = @{} ) diff --git a/src/Tools/Routes.ps1 b/src/Tools/Routes.ps1 index af0d0b99b..b576fa4c0 100644 --- a/src/Tools/Routes.ps1 +++ b/src/Tools/Routes.ps1 @@ -117,6 +117,20 @@ function Route throw "[$($HttpMethod)] $($Route) is already defined" } + # if we have middleware, convert scriptblocks to hashtables + if (!(Test-Empty $Middleware)) + { + $Middleware = @($Middleware) + for ($i = 0; $i -lt $Middleware.Length; $i++) { + if ((Get-Type $Middleware[$i]).Name -ieq 'scriptblock') + { + $Middleware[$i] = @{ + 'Logic' = $Middleware[$i] + } + } + } + } + # add the route logic $PodeSession.Server.Routes[$HttpMethod][$Route] = @{ 'Logic' = $ScriptBlock; diff --git a/src/Tools/Server.ps1 b/src/Tools/Server.ps1 index a545d7104..9e111d03d 100644 --- a/src/Tools/Server.ps1 +++ b/src/Tools/Server.ps1 @@ -101,11 +101,14 @@ function Server # sit here waiting for termination (unless it's one-off script) if ($PodeSession.Server.Type -ine 'script') { - while (!(Test-TerminationPressed)) { + while (!(Test-TerminationPressed -Key $key)) { Start-Sleep -Seconds 1 + # get the next key presses + $key = Get-ConsoleKey + # check for internal restart - if ($PodeSession.Tokens.Restart.IsCancellationRequested) { + if (($PodeSession.Tokens.Restart.IsCancellationRequested) -or (Test-RestartPressed -Key $key)) { Restart-PodeServer } } @@ -128,7 +131,7 @@ function Start-PodeServer try { # run the logic - Invoke-ScriptBlock -ScriptBlock $PodeSession.Server.Logic + Invoke-ScriptBlock -ScriptBlock $PodeSession.Server.Logic -NoNewClosure # start runspace for timers Start-TimerRunspace @@ -164,7 +167,7 @@ function Start-PodeServer } Start-Sleep -Seconds $PodeSession.Server.Interval - Invoke-ScriptBlock -ScriptBlock $PodeSession.Server.Logic + Invoke-ScriptBlock -ScriptBlock $PodeSession.Server.Logic -NoNewClosure } } } @@ -201,12 +204,19 @@ function Restart-PodeServer $PodeSession.Schedules.Clear() $PodeSession.Loggers.Clear() - # clear middleware - $PodeSession.Server.Middleware.Clear() + # clear middle/endware + $PodeSession.Server.Middleware = @() + $PodeSession.Server.Endware = @() # clear up view engine $PodeSession.Server.ViewEngine.Clear() + # clear up cookie sessions + $PodeSession.Server.Cookies.Session.Clear() + + # clear up authentication methods + $PodeSession.Server.Authentications.Clear() + # clear up shared state $PodeSession.Server.State.Clear() diff --git a/src/Tools/Session.ps1 b/src/Tools/Session.ps1 index 0cb628084..9594af918 100644 --- a/src/Tools/Session.ps1 +++ b/src/Tools/Session.ps1 @@ -117,6 +117,14 @@ function New-PodeSession 'Active' = @{}; } + # cookies and session logic + $session.Server.Cookies = @{ + 'Session' = @{}; + } + + # authnetication methods + $session.Server.Authentications = @{} + # create new cancellation tokens $session.Tokens = @{ 'Cancellation' = New-Object System.Threading.CancellationTokenSource; @@ -129,6 +137,9 @@ function New-PodeSession # middleware that needs to run $session.Server.Middleware = @() + # endware that needs to run + $session.Server.Endware = @() + # runspace pools $session.RunspacePools = @{ 'Main' = $null; diff --git a/src/Tools/Timers.ps1 b/src/Tools/Timers.ps1 index e4ccda0ca..0e842249f 100644 --- a/src/Tools/Timers.ps1 +++ b/src/Tools/Timers.ps1 @@ -44,7 +44,7 @@ function Start-TimerRunspace if ($run) { try { - Invoke-ScriptBlock -ScriptBlock (($_.Script).GetNewClosure()) -Arguments @{ 'Lockable' = $PodeSession.Lockable } -Scoped + Invoke-ScriptBlock -ScriptBlock $_.Script -Arguments @{ 'Lockable' = $PodeSession.Lockable } -Scoped } catch { $Error[0] diff --git a/src/Tools/WebServer.ps1 b/src/Tools/WebServer.ps1 index 0a3e9cfa7..c02fba1b4 100644 --- a/src/Tools/WebServer.ps1 +++ b/src/Tools/WebServer.ps1 @@ -106,6 +106,8 @@ function Start-WebServer # reset session data $WebSession = @{} + $WebSession.OnEnd = @() + $WebSession.Auth = @{} $WebSession.Response = $response $WebSession.Request = $request $WebSession.Lockable = $PodeSession.Lockable @@ -116,8 +118,7 @@ function Start-WebServer $logObject = New-PodeLogObject -Request $request -Path $WebSession.Path # invoke middleware - $_midware = ($PodeSession.Server.Middleware).Logic - if ((Invoke-PodeMiddleware -Session $WebSession -Middleware $_midware)) { + if ((Invoke-PodeMiddleware -Session $WebSession -Middleware $PodeSession.Server.Middleware)) { # get the route logic $route = Get-PodeRoute -HttpMethod $WebSession.Method -Route $WebSession.Path if ($null -eq $route) { @@ -126,10 +127,14 @@ function Start-WebServer # invoke route and custom middleware if ((Invoke-PodeMiddleware -Session $WebSession -Middleware $route.Middleware)) { - Invoke-ScriptBlock -ScriptBlock (($route.Logic).GetNewClosure()) -Arguments $WebSession -Scoped + Invoke-ScriptBlock -ScriptBlock $route.Logic -Arguments $WebSession -Scoped } } + # invoke endware specifc to the current websession + $_endware = ($WebSession.OnEnd + @(($PodeSession.Server.Endware).Logic)) + Invoke-PodeEndware -Session $WebSession -Endware $_endware + # close response stream (check if exists, as closing the writer closes this stream on unix) if ($response.OutputStream) { dispose $response.OutputStream -Close -CheckNetwork diff --git a/tests/unit/Tools/Authentication.Tests.ps1 b/tests/unit/Tools/Authentication.Tests.ps1 new file mode 100644 index 000000000..0e821ad83 --- /dev/null +++ b/tests/unit/Tools/Authentication.Tests.ps1 @@ -0,0 +1,82 @@ +$path = $MyInvocation.MyCommand.Path +$src = (Split-Path -Parent -Path $path) -ireplace '\\tests\\unit\\', '\src\' +Get-ChildItem "$($src)\*.ps1" | Resolve-Path | ForEach-Object { . $_ } + +$now = [datetime]::UtcNow + +Describe 'Set-PodeAuthStatus' { + Mock 'redirect' {} + Mock 'status' {} + + It 'Redirects to a failure URL' { + Set-PodeAuthStatus -StatusCode 500 -Options @{'FailureUrl' = 'url'} | Should Be $false + Assert-MockCalled 'redirect' -Times 1 -Scope It + Assert-MockCalled 'status' -Times 0 -Scope It + } + + It 'Sets status to failure' { + Set-PodeAuthStatus -StatusCode 500 -Options @{} | Should Be $false + Assert-MockCalled 'redirect' -Times 0 -Scope It + Assert-MockCalled 'status' -Times 1 -Scope It + } + + It 'Redirects to a success URL' { + Set-PodeAuthStatus -Options @{'SuccessUrl' = 'url'} | Should Be $false + Assert-MockCalled 'redirect' -Times 1 -Scope It + Assert-MockCalled 'status' -Times 0 -Scope It + } + + It 'Returns true for next middleware' { + Set-PodeAuthStatus -Options @{} | Should Be $true + Assert-MockCalled 'redirect' -Times 0 -Scope It + Assert-MockCalled 'status' -Times 0 -Scope It + } +} + +Describe 'Get-PodeAuthBasic' { + Context 'Invalid parameters supplied' { + It 'Throws null value error' { + { Get-PodeAuthBasic -ScriptBlock $null } | Should Throw 'argument is null' + } + } + + Context 'Valid parameters' { + It 'Returns auth data' { + $result = Get-PodeAuthBasic -ScriptBlock { Write-Host 'Hello' } + + $result | Should Not Be $null + $result.Name | Should Be 'Basic' + + $result.Parser | Should Not Be $null + $result.Parser.GetType().Name | Should Be 'ScriptBlock' + + $result.Validator | Should Not Be $null + $result.Validator.GetType().Name | Should Be 'ScriptBlock' + $result.Validator.ToString() | Should Be ({ Write-Host 'Hello' }).ToString() + } + } +} + +Describe 'Get-PodeAuthForm' { + Context 'Invalid parameters supplied' { + It 'Throws null value error' { + { Get-PodeAuthForm -ScriptBlock $null } | Should Throw 'argument is null' + } + } + + Context 'Valid parameters' { + It 'Returns auth data' { + $result = Get-PodeAuthForm -ScriptBlock { Write-Host 'Hello' } + + $result | Should Not Be $null + $result.Name | Should Be 'Form' + + $result.Parser | Should Not Be $null + $result.Parser.GetType().Name | Should Be 'ScriptBlock' + + $result.Validator | Should Not Be $null + $result.Validator.GetType().Name | Should Be 'ScriptBlock' + $result.Validator.ToString() | Should Be ({ Write-Host 'Hello' }).ToString() + } + } +} \ No newline at end of file diff --git a/tests/unit/Tools/Cookies.Tests.ps1 b/tests/unit/Tools/Cookies.Tests.ps1 new file mode 100644 index 000000000..21070a6ce --- /dev/null +++ b/tests/unit/Tools/Cookies.Tests.ps1 @@ -0,0 +1,181 @@ +$path = $MyInvocation.MyCommand.Path +$src = (Split-Path -Parent -Path $path) -ireplace '\\tests\\unit\\', '\src\' +Get-ChildItem "$($src)\*.ps1" | Resolve-Path | ForEach-Object { . $_ } + +$now = [datetime]::UtcNow + +Describe 'Get-PodeSessionCookie' { + Context 'Invalid parameters supplied' { + It 'Throws null value error' { + { Get-PodeSessionCookie -Request $null } | Should Throw 'argument is null' + } + } + + Context 'Valid parameters' { + It 'Returns no session details for invalid sessionId' { + $Request = @{ + 'Cookies' = @{} + } + + $PodeSession = @{ + 'Server' = @{ 'Cookies' = @{ 'Session' = @{ + 'Name' = 'pode.sid'; + 'SecretKey' = 'key'; + 'Info' = @{ 'Duration' = 60; }; + } } } + } + + $data = Get-PodeSessionCookie -Request $Request + $data | Should Be $null + } + + It 'Returns no session details for invalid signed sessionId' { + $Request = @{ + 'Cookies' = @{ + 'pode.sid' = @{ + 'Value' = 's:value.kPv88V5o2uJ29sqh2a7P/f3dxcg+JdZJZT3GTIE='; + 'Name' = 'pode.sid'; + 'TimeStamp' = $now; + } + } + } + + $PodeSession = @{ + 'Server' = @{ 'Cookies' = @{ 'Session' = @{ + 'Name' = 'pode.sid'; + 'SecretKey' = 'key'; + 'Info' = @{ 'Duration' = 60; }; + } } } + } + + $data = Get-PodeSessionCookie -Request $Request + $data | Should Be $null + } + + It 'Returns session details' { + $Request = @{ + 'Cookies' = @{ + 'pode.sid' = @{ + 'Value' = 's:value.kPv88V50o2uJ29sqch2a7P/f3dxcg+J/dZJZT3GTJIE='; + 'Name' = 'pode.sid'; + 'TimeStamp' = $now; + } + } + } + + $PodeSession = @{ + 'Server' = @{ 'Cookies' = @{ 'Session' = @{ + 'Name' = 'pode.sid'; + 'SecretKey' = 'key'; + 'Info' = @{ 'Duration' = 60; }; + } } } + } + + $data = Get-PodeSessionCookie -Request $Request + $data | Should Not Be $null + $data.Id | Should Be 'value' + $data.Name | Should Be 'pode.sid' + $data.Cookie.TimeStamp | Should Be $now + $data.Cookie.Duration | Should Be 60 + } + } +} + +Describe 'Set-PodeSessionCookieDataHash' { + Context 'Invalid parameters supplied' { + It 'Throws null value error' { + { Set-PodeSessionCookieDataHash -Session $null } | Should Throw 'argument is null' + } + } + + Context 'Valid parameters' { + It 'Sets a hash for no data' { + $Session = @{} + Set-PodeSessionCookieDataHash -Session $Session + $Session.Data | Should Not Be $null + $Session.DataHash | Should Be 'xvgoFiDCuHz2qU9SMxHq6XfkIO+abNqGZ/Yb6QbOypA=' + } + + It 'Sets a hash for data' { + $Session = @{ 'Data' = @{ 'Counter' = 2; } } + Set-PodeSessionCookieDataHash -Session $Session + $Session.Data | Should Not Be $null + $Session.DataHash | Should Be 'gG2dPsmPKL6v/ZpMBpPu+lh0lu0dfC8nsa48oJAndMo=' + } + } +} + +Describe 'New-PodeSessionCookie' { + Mock 'Invoke-ScriptBlock' { return 'value' } + + It 'Creates a new session object' { + $PodeSession = @{ + 'Server' = @{ 'Cookies' = @{ 'Session' = @{ + 'Name' = 'pode.sid'; + 'SecretKey' = 'key'; + 'Info' = @{ 'Duration' = 60; }; + 'GenerateId' = {} + } } } + } + + $session = New-PodeSessionCookie + + $session | Should Not Be $null + $session.Id | Should Be 'value' + $session.Name | Should Be 'pode.sid' + $session.Data.Count | Should Be 0 + $session.Cookie.Duration | Should Be 60 + $session.DataHash | Should Be 'xvgoFiDCuHz2qU9SMxHq6XfkIO+abNqGZ/Yb6QbOypA=' + } +} + +Describe 'Test-PodeSessionCookieDataHash' { + Context 'Invalid parameters supplied' { + It 'Throws null value error' { + { Test-PodeSessionCookieDataHash -Session $null } | Should Throw 'argument is null' + } + } + + Context 'Valid parameters' { + It 'Returns false for no hash set' { + $Session = {} + Test-PodeSessionCookieDataHash -Session $Session | Should Be $false + } + + It 'Returns false for invalid hash' { + $Session = @{ 'DataHash' = 'fake' } + Test-PodeSessionCookieDataHash -Session $Session | Should Be $false + } + + It 'Returns true for a valid hash' { + $Session = @{ + 'Data' = @{ 'Counter' = 2; }; + 'DataHash' = 'gG2dPsmPKL6v/ZpMBpPu+lh0lu0dfC8nsa48oJAndMo='; + } + + Test-PodeSessionCookieDataHash -Session $Session | Should Be $true + } + } +} + +Describe 'Get-PodeSessionCookieInMemStore' { + It 'Returns a valid storage object' { + $store = Get-PodeSessionCookieInMemStore + $store | Should Not Be $null + + $members = @(($store | Get-Member).Name) + $members.Contains('Memory' ) | Should Be $true + $members.Contains('Delete' ) | Should Be $true + $members.Contains('Get' ) | Should Be $true + $members.Contains('Set' ) | Should Be $true + } +} + +Describe 'Set-PodeSessionCookieInMemClearDown' { + It 'Adds a new schedule for clearing down' { + $PodeSession = @{ 'Schedules' = @{}} + Set-PodeSessionCookieInMemClearDown + $PodeSession.Schedules.Count | Should Be 1 + $PodeSession.Schedules.Contains('__pode_session_inmem_cleanup__') | Should Be $true + } +} \ No newline at end of file diff --git a/tests/unit/Tools/Cryptography.Tests.ps1 b/tests/unit/Tools/Cryptography.Tests.ps1 new file mode 100644 index 000000000..6a1a6900e --- /dev/null +++ b/tests/unit/Tools/Cryptography.Tests.ps1 @@ -0,0 +1,99 @@ +$path = $MyInvocation.MyCommand.Path +$src = (Split-Path -Parent -Path $path) -ireplace '\\tests\\unit\\', '\src\' +Get-ChildItem "$($src)\*.ps1" | Resolve-Path | ForEach-Object { . $_ } + +Describe 'Invoke-HMACSHA256Hash' { + Context 'Invalid parameters supplied' { + It 'Throws null value error' { + { Invoke-HMACSHA256Hash -Value $null -Secret 'key' } | Should Throw 'argument is null or empty' + } + + It 'Throws empty value error' { + { Invoke-HMACSHA256Hash -Value '' -Secret 'key' } | Should Throw 'argument is null or empty' + } + + It 'Throws null secret error' { + { Invoke-HMACSHA256Hash -Value 'value' -Secret $null } | Should Throw 'argument is null or empty' + } + + It 'Throws empty secret error' { + { Invoke-HMACSHA256Hash -Value 'value' -Secret '' } | Should Throw 'argument is null or empty' + } + } + + Context 'Valid parameters' { + It 'Returns encrypted data' { + Invoke-HMACSHA256Hash -Value 'value' -Secret 'key' | Should Be 'kPv88V50o2uJ29sqch2a7P/f3dxcg+J/dZJZT3GTJIE=' + } + } +} + +Describe 'Invoke-SHA256Hash' { + Context 'Invalid parameters supplied' { + It 'Throws null value error' { + { Invoke-SHA256Hash -Value $null } | Should Throw 'argument is null or empty' + } + + It 'Throws empty value error' { + { Invoke-SHA256Hash -Value '' } | Should Throw 'argument is null or empty' + } + } + + Context 'Valid parameters' { + It 'Returns encrypted data' { + Invoke-SHA256Hash -Value 'value' | Should Be 'zUJATVKtVcz6mspK3IKKpYAK2dOFoGcfvL9yQRgyBhk=' + } + } +} + +Describe 'Invoke-CookieSign' { + Context 'Invalid parameters supplied' { + It 'Throws null value error' { + { Invoke-CookieSign -Value $null -Secret 'key' } | Should Throw 'argument is null or empty' + } + + It 'Throws empty value error' { + { Invoke-CookieSign -Value '' -Secret 'key' } | Should Throw 'argument is null or empty' + } + + It 'Throws null secret error' { + { Invoke-CookieSign -Value 'value' -Secret $null } | Should Throw 'argument is null or empty' + } + + It 'Throws empty secret error' { + { Invoke-CookieSign -Value 'value' -Secret '' } | Should Throw 'argument is null or empty' + } + } + + Context 'Valid parameters' { + It 'Returns signed encrypted data' { + Invoke-CookieSign -Value 'value' -Secret 'key' | Should Be 's:value.kPv88V50o2uJ29sqch2a7P/f3dxcg+J/dZJZT3GTJIE=' + } + } +} + +Describe 'Invoke-CookieUnsign' { + Context 'Invalid parameters supplied' { + It 'Throws null value error' { + { Invoke-CookieUnsign -Signature $null -Secret 'key' } | Should Throw 'argument is null or empty' + } + + It 'Throws empty value error' { + { Invoke-CookieUnsign -Signature '' -Secret 'key' } | Should Throw 'argument is null or empty' + } + + It 'Throws null secret error' { + { Invoke-CookieUnsign -Signature 'value' -Secret $null } | Should Throw 'argument is null or empty' + } + + It 'Throws empty secret error' { + { Invoke-CookieUnsign -Signature 'value' -Secret '' } | Should Throw 'argument is null or empty' + } + } + + Context 'Valid parameters' { + It 'Returns signed encrypted data' { + Invoke-CookieUnsign -Signature 's:value.kPv88V50o2uJ29sqch2a7P/f3dxcg+J/dZJZT3GTJIE=' -Secret 'key' | Should Be 'value' + } + } +} \ No newline at end of file diff --git a/tests/unit/Tools/Endware.Tests.ps1 b/tests/unit/Tools/Endware.Tests.ps1 new file mode 100644 index 000000000..44a44ed68 --- /dev/null +++ b/tests/unit/Tools/Endware.Tests.ps1 @@ -0,0 +1,33 @@ +$path = $MyInvocation.MyCommand.Path +$src = (Split-Path -Parent -Path $path) -ireplace '\\tests\\unit\\', '\src\' +Get-ChildItem "$($src)\*.ps1" | Resolve-Path | ForEach-Object { . $_ } + +Describe 'Endware' { + Context 'Invalid parameters supplied' { + It 'Throws null logic error' { + { Endware -ScriptBlock $null } | Should Throw 'argument is null' + } + } + + Context 'Valid parameters' { + It 'Adds single Endware to list' { + $PodeSession = @{ 'Server' = @{ 'Endware' = @(); }; } + + Endware -ScriptBlock { write-host 'end1' } + + $PodeSession.Server.Endware.Length | Should Be 1 + $PodeSession.Server.Endware[0].ToString() | Should Be ({ Write-Host 'end1' }).ToString() + } + + It 'Adds two Endwares to list' { + $PodeSession = @{ 'Server' = @{ 'Endware' = @(); }; } + + Endware -ScriptBlock { write-host 'end1' } + Endware -ScriptBlock { write-host 'end2' } + + $PodeSession.Server.Endware.Length | Should Be 2 + $PodeSession.Server.Endware[0].ToString() | Should Be ({ Write-Host 'end1' }).ToString() + $PodeSession.Server.Endware[1].ToString() | Should Be ({ Write-Host 'end2' }).ToString() + } + } +} \ No newline at end of file diff --git a/tests/unit/Tools/Helpers.Tests.ps1 b/tests/unit/Tools/Helpers.Tests.ps1 index 93b05c6fd..c178d9292 100644 --- a/tests/unit/Tools/Helpers.Tests.ps1 +++ b/tests/unit/Tools/Helpers.Tests.ps1 @@ -88,6 +88,10 @@ Describe 'Test-Empty' { It 'Return true for a whitespace string' { Test-Empty -Value " " | Should Be $true } + + It 'Return true for an empty scriptblock' { + Test-Empty -Value {} | Should Be $true + } } Context 'Valid value is passed' { @@ -106,6 +110,10 @@ Describe 'Test-Empty' { It 'Return false for a hashtable' { Test-Empty -Value @{'key'='value';} | Should Be $false } + + It 'Return false for a scriptblock' { + Test-Empty -Value { write-host '' } | Should Be $false + } } } diff --git a/tests/unit/Tools/Routes.Tests.ps1 b/tests/unit/Tools/Routes.Tests.ps1 index a5462ed5d..9cef1d3ef 100644 --- a/tests/unit/Tools/Routes.Tests.ps1 +++ b/tests/unit/Tools/Routes.Tests.ps1 @@ -137,7 +137,9 @@ Describe 'Route' { $route | Should Not Be $null $route.Logic.ToString() | Should Be ({ Write-Host 'logic' }).ToString() - $route.Middleware.ToString() | Should Be ({ Write-Host 'middle' }).ToString() + + $route.Middleware.Length | Should Be 1 + $route.Middleware[0].Logic.ToString() | Should Be ({ Write-Host 'middle' }).ToString() } It 'Throws error for route with array of middleware and no logic supplied' { @@ -170,8 +172,8 @@ Describe 'Route' { $route.Logic.ToString() | Should Be ({ Write-Host 'logic' }).ToString() $route.Middleware.Length | Should Be 2 - $route.Middleware[0].ToString() | Should Be ({ Write-Host 'middle1' }).ToString() - $route.Middleware[1].ToString() | Should Be ({ Write-Host 'middle2' }).ToString() + $route.Middleware[0].Logic.ToString() | Should Be ({ Write-Host 'middle1' }).ToString() + $route.Middleware[1].Logic.ToString() | Should Be ({ Write-Host 'middle2' }).ToString() } It 'Adds route with simple url and querystring' {