Skip to content

Commit

Permalink
Add anonymous user middleware package (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
jorros authored Mar 3, 2022
1 parent dd33f17 commit d5ba969
Show file tree
Hide file tree
Showing 15 changed files with 534 additions and 1 deletion.
26 changes: 26 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/tests/AnonymousUserTests/bin/Debug/net6.0/AnonymousUserTests.dll",
"args": [],
"cwd": "${workspaceFolder}/tests/AnonymousUserTests",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "internalConsole",
"stopAtEntry": false
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}
41 changes: 41 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/tests/AnonymousUserTests/AnonymousUserTests.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/tests/AnonymousUserTests/AnonymousUserTests.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/tests/AnonymousUserTests/AnonymousUserTests.csproj"
],
"problemMatcher": "$msCompile"
}
]
}
38 changes: 38 additions & 0 deletions AspNetCoreExtensions.sln
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.6.30114.105
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{38DD8F4A-2C3A-486C-AE3A-7208007EB1B5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnonymousUser", "src\AnonymousUser\AnonymousUser.csproj", "{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9CEDA0A6-C7E0-4542-896C-241C3D796D89}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnonymousUserTests", "tests\AnonymousUserTests\AnonymousUserTests.csproj", "{82E9B456-9918-4499-A165-A30423BA0740}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -15,4 +23,34 @@ Global
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Debug|x64.ActiveCfg = Debug|Any CPU
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Debug|x64.Build.0 = Debug|Any CPU
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Debug|x86.ActiveCfg = Debug|Any CPU
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Debug|x86.Build.0 = Debug|Any CPU
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Release|Any CPU.Build.0 = Release|Any CPU
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Release|x64.ActiveCfg = Release|Any CPU
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Release|x64.Build.0 = Release|Any CPU
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Release|x86.ActiveCfg = Release|Any CPU
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96}.Release|x86.Build.0 = Release|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Debug|Any CPU.Build.0 = Debug|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Debug|x64.ActiveCfg = Debug|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Debug|x64.Build.0 = Debug|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Debug|x86.ActiveCfg = Debug|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Debug|x86.Build.0 = Debug|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Release|Any CPU.ActiveCfg = Release|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Release|Any CPU.Build.0 = Release|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Release|x64.ActiveCfg = Release|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Release|x64.Build.0 = Release|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Release|x86.ActiveCfg = Release|Any CPU
{82E9B456-9918-4499-A165-A30423BA0740}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{9FB11E67-7313-4BC7-9412-3FF4CAF35C96} = {38DD8F4A-2C3A-486C-AE3A-7208007EB1B5}
{82E9B456-9918-4499-A165-A30423BA0740} = {9CEDA0A6-C7E0-4542-896C-241C3D796D89}
EndGlobalSection
EndGlobal
1 change: 1 addition & 0 deletions CodeAnalysis.Library.ruleset
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<Rule Id="CA1006" Action="None" />
<Rule Id="CA1303" Action="None" />
<Rule Id="CA2243" Action="None" />
<Rule Id="CA2007" Action="None" />
</Rules>
<Rules AnalyzerId="StyleCop.Analyzers" RuleNamespace="StyleCop.Analyzers">
<Rule Id="SA1101" Action="None" /> <!-- Prefix local calls with this -->
Expand Down
2 changes: 1 addition & 1 deletion appveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ max_jobs: 1
environment:
CAKE_SETTINGS_SKIPPACKAGEVERSIONCHECK: "true"

image: Visual Studio 2019
image: Visual Studio 2022

cache:
- '%LocalAppData%\NuGet\v3-cache'
Expand Down
11 changes: 11 additions & 0 deletions src/AnonymousUser/AnonymousUser.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
</ItemGroup>

</Project>
27 changes: 27 additions & 0 deletions src/AnonymousUser/AnonymousUserExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System;
using InsightArchitectures.Extensions.AspNetCore.AnonymousUser;

namespace Microsoft.AspNetCore.Builder
{
/// <summary>
/// Extension methods for the ASP NET Core application builder.
/// </summary>
public static class AnonymousUserExtensions
{
/// <summary>
/// Adds the <see cref="AnonymousUserMiddleware" /> to the middleware pipeline.
/// </summary>
/// <param name="builder">The application builder object.</param>
/// <param name="configure">An action to customise the middleware options.</param>
public static IApplicationBuilder UseAnonymousUser(this IApplicationBuilder builder, Action<AnonymousUserOptions> configure = null)
{
var options = new AnonymousUserOptions();

configure?.Invoke(options);

_ = options.ClaimType ?? throw new NullReferenceException($"{nameof(options.ClaimType)} is null. Please provide a claim type name when configuring the middleware.");

return builder.UseMiddleware<AnonymousUserMiddleware>(options);
}
}
}
78 changes: 78 additions & 0 deletions src/AnonymousUser/AnonymousUserMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

namespace InsightArchitectures.Extensions.AspNetCore.AnonymousUser
{
/// <summary>
/// The anonymous user middleware. It either creates a new or reads an existing cookie
/// and maps the value to a claim.
/// </summary>
public class AnonymousUserMiddleware
{
private RequestDelegate _nextDelegate;
private AnonymousUserOptions _options;

/// <summary>
/// Constructor requires the next delegate and options.
/// </summary>
public AnonymousUserMiddleware(RequestDelegate nextDelegate, AnonymousUserOptions options)
{
_nextDelegate = nextDelegate ?? throw new ArgumentNullException(nameof(nextDelegate));
_options = options ?? throw new ArgumentNullException(nameof(options));
}

private async Task HandleRequestAsync(HttpContext httpContext)
{
var cookieEncoder = _options.EncoderService ?? throw new ArgumentNullException(nameof(_options.EncoderService), $"{nameof(_options.EncoderService)} is null and should have a valid encoder.");
_ = _options.UserIdentifierFactory ?? throw new ArgumentNullException(nameof(_options.UserIdentifierFactory), $"{nameof(_options.UserIdentifierFactory)} is null and should have a valid factory.");

if (httpContext.User.Identity?.IsAuthenticated == true)
{
return;
}

var encodedValue = httpContext.Request.Cookies[_options.CookieName];

if (_options.Secure && !httpContext.Request.IsHttps)
{
if (!string.IsNullOrWhiteSpace(encodedValue))
{
httpContext.Response.Cookies.Delete(_options.CookieName);
}

return;
}

var uid = await cookieEncoder.DecodeAsync(encodedValue);

if (string.IsNullOrWhiteSpace(uid))
{
uid = _options.UserIdentifierFactory.Invoke(httpContext);
var encodedUid = await cookieEncoder.EncodeAsync(uid);

var cookieOptions = new CookieOptions
{
Expires = _options.Expires,
};

httpContext.Response.Cookies.Append(_options.CookieName, encodedUid, cookieOptions);
}

var identity = new ClaimsIdentity(new[] { new Claim(_options.ClaimType, uid) });
httpContext.User.AddIdentity(identity);
}

/// <summary>
/// Called by the pipeline, runs the handler.
/// </summary>
public async Task InvokeAsync(HttpContext httpContext)
{
_ = httpContext ?? throw new ArgumentNullException(nameof(httpContext));
await HandleRequestAsync(httpContext);

await _nextDelegate.Invoke(httpContext);
}
}
}
29 changes: 29 additions & 0 deletions src/AnonymousUser/AnonymousUserOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;
using Microsoft.AspNetCore.Http;

namespace InsightArchitectures.Extensions.AspNetCore.AnonymousUser
{
/// <summary>
/// Configuration options for the middleware.
/// </summary>
public class AnonymousUserOptions
{
/// <summary>The name of the cookie.</summary>
public string CookieName { get; set; } = "tid";

/// <summary>The expiration date of the cookie. Default set to 10 years.</summary>
public DateTimeOffset Expires { get; set; } = DateTimeOffset.UtcNow.AddDays(3652);

/// <summary>The type name of the claim holding the ID.</summary>
public string ClaimType { get; set; } = "ExternalId";

/// <summary>Should the cookie only be allowed on https requests.</summary>
public bool Secure { get; set; }

/// <summary>Can be overridden to customise the ID generation.</summary>
public Func<HttpContext, string> UserIdentifierFactory { get; set; } = _ => Guid.NewGuid().ToString();

/// <summary>The encoder service to encode/decode the cookie value. Default set to internal base64 encoder.</summary>
public ICookieEncoder EncoderService { get; set; } = new Base64CookieEncoder();
}
}
46 changes: 46 additions & 0 deletions src/AnonymousUser/Base64CookieEncoder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System;
using System.Text;
using System.Threading.Tasks;

namespace InsightArchitectures.Extensions.AspNetCore.AnonymousUser
{
/// <summary>
/// Default cookie value encoder/decoder. Uses base64 for serialisation.
/// </summary>
public class Base64CookieEncoder : ICookieEncoder
{
/// <summary>
/// Deserialises a base64 value into clear text.
/// <param name="encodedValue">A base64 encoded value.</param>
/// <returns>Returns null if <paramref name="encodedValue" /> is null, otherwise the decoded value.</returns>
/// </summary>
public Task<string> DecodeAsync(string encodedValue)
{
if (string.IsNullOrWhiteSpace(encodedValue))
{
return Task.FromResult((string)null);
}

var bytes = Convert.FromBase64String(encodedValue);

return Task.FromResult(Encoding.UTF8.GetString(bytes));
}

/// <summary>
/// Serialiases a clear text value into base64.
/// <param name="value">A clear text value.</param>
/// <returns>Returns null if <paramref name="value" /> is null, otherwise the encoded value.</returns>
/// </summary>
public Task<string> EncodeAsync(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return Task.FromResult((string)null);
}

var bytes = Encoding.UTF8.GetBytes(value);

return Task.FromResult(Convert.ToBase64String(bytes));
}
}
}
22 changes: 22 additions & 0 deletions src/AnonymousUser/ICookieEncoder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Threading.Tasks;

namespace InsightArchitectures.Extensions.AspNetCore.AnonymousUser
{
/// <summary>
/// Interface for cookie value serialisation.
/// </summary>
public interface ICookieEncoder
{
/// <summary>
/// Serialiases a clear text value.
/// <param name="value">A clear text value</param>
/// </summary>
Task<string> EncodeAsync(string value);

/// <summary>
/// Deserialises the given value into clear text.
/// <param name="encodedValue">The serialised value</param>
/// </summary>
Task<string> DecodeAsync(string encodedValue);
}
}
Loading

0 comments on commit d5ba969

Please sign in to comment.