From 307fb4005f939a29d57dba24de7234edb95febf7 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Wed, 10 Jun 2026 11:13:14 +0200 Subject: [PATCH 1/2] Modernize the example and add a .NET Framework sample - Upgrade the ASP.NET Core sample to net8.0 and migrate from Startup.cs to minimal hosting in Program.cs; switch to AddDefaultIdentity. - Drop the Microsoft.VisualStudio.Threading dependency; fire-and-forget Castle calls now use a discard. Remove unused scaffolding and the SQL Server migrations (the app uses the in-memory provider). - Add a demo catalog (Demos/DemoCatalog.cs) surfaced on the home page. - Add a Dockerfile for the ASP.NET Core sample and refresh the README and appsettings. - Add a minimal net48 (System.Web) console sample exercising Context.FromHttpRequest(HttpRequestBase). - Add GitHub Actions: an Ubuntu job builds the ASP.NET Core sample and a Windows job builds the .NET Framework sample. --- .github/workflows/build.yml | 27 ++ Dockerfile | 14 ++ README.md | 75 ++++-- .../CastleDemo.Framework.csproj | 14 ++ src/CastleDemo.Framework/DemoHttpRequest.cs | 29 +++ src/CastleDemo.Framework/Program.cs | 59 +++++ src/CastleDemo.sln | 6 + .../Identity/Pages/Account/Login.cshtml.cs | 9 +- .../Identity/Pages/Filter/Filter.cshtml.cs | 1 - .../Areas/Identity/Pages/Log/Log.cshtml.cs | 1 - .../Areas/Identity/Pages/Risk/Risk.cshtml.cs | 1 - .../Areas/Identity/Pages/_ViewImports.cshtml | 1 - src/CastleDemo/CastleDemo.csproj | 23 +- ...000000000_CreateIdentitySchema.Designer.cs | 236 ------------------ .../00000000000000_CreateIdentitySchema.cs | 220 ---------------- .../ApplicationDbContextModelSnapshot.cs | 234 ----------------- src/CastleDemo/Demos/DemoCatalog.cs | 73 ++++++ src/CastleDemo/Pages/Index.cshtml | 22 +- src/CastleDemo/Pages/Index.cshtml.cs | 10 +- src/CastleDemo/Program.cs | 70 ++++-- src/CastleDemo/ScaffoldingReadme.txt | 18 -- src/CastleDemo/Startup.cs | 72 ------ 22 files changed, 359 insertions(+), 856 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 Dockerfile create mode 100644 src/CastleDemo.Framework/CastleDemo.Framework.csproj create mode 100644 src/CastleDemo.Framework/DemoHttpRequest.cs create mode 100644 src/CastleDemo.Framework/Program.cs delete mode 100644 src/CastleDemo/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs delete mode 100644 src/CastleDemo/Data/Migrations/00000000000000_CreateIdentitySchema.cs delete mode 100644 src/CastleDemo/Data/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 src/CastleDemo/Demos/DemoCatalog.cs delete mode 100644 src/CastleDemo/ScaffoldingReadme.txt delete mode 100644 src/CastleDemo/Startup.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0470bf3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build + +on: + push: + branches: [main, master] + pull_request: + +jobs: + core: + name: ASP.NET Core sample (Linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - run: dotnet build src/CastleDemo/CastleDemo.csproj -c Release + + framework: + name: .NET Framework sample (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - run: dotnet build src/CastleDemo.Framework/CastleDemo.Framework.csproj -c Release diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d0e138e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Build and run the ASP.NET Core CastleDemo sample. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY src/CastleDemo/CastleDemo.csproj CastleDemo/ +RUN dotnet restore CastleDemo/CastleDemo.csproj +COPY src/CastleDemo/ CastleDemo/ +RUN dotnet publish CastleDemo/CastleDemo.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "CastleDemo.dll"] diff --git a/README.md b/README.md index ce02278..ddc14bc 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,70 @@ # Castle .NET Example -This is an example of integrating Castle with a standard ASP.NET Core Razor Pages application. +Examples of integrating Castle into .NET applications. The repository contains two samples: -## Foundation +| Sample | Path | Target | Notes | +| --- | --- | --- | --- | +| ASP.NET Core Razor Pages | `src/CastleDemo` | `net8.0` | The main, cross-platform sample. | +| .NET Framework console | `src/CastleDemo.Framework` | `net48` | Exercises the `System.Web` code path; builds and runs on Windows. | -The example is almost fully from the default Visual Studio template for a Razor Pages app with _Invididual user accounts_ for authentication. +## ASP.NET Core sample (`src/CastleDemo`) -### Framework +A Razor Pages app based on the default template with _Individual user accounts_ for +authentication. The home page lists the available demos (see `Demos/DemoCatalog.cs`), +each of which triggers a Castle API call. -NET Core 6.0 +### Highlights -### Template modifications - -- The database runs in-memory +- Targets `net8.0` and uses minimal hosting (`Program.cs`); there is no `Startup.cs`. +- The database runs in-memory: ```csharp -services.AddDbContext(options => - options.UseInMemoryDatabase("CastleDemo") -); +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("CastleDemo")); ``` -- Visual Studio scaffolding has been used to create a Login page we can alter, as described by Microsoft [here](https://docs.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity). -- We use [Microsoft.VisualStudio.Threading](https://www.nuget.org/packages/Microsoft.VisualStudio.Threading/) to get access to the `Forget()` extension method, which is useful for fire-and-forget calls to async methods, like **Track** or **Authenticate** in Monitor mode. +- Fire-and-forget Castle calls (e.g. **Track** / **Authenticate** in monitor mode) use a + discard (`_ = client.Track(...)`) rather than an external dependency. +- Client-side fingerprinting and secure mode are wired up in `Pages/Shared/_Layout.cshtml`. + +All Castle-related changes are marked with comments containing the word _Castle_ for easy +searching, and primarily affect: + +- `Program.cs` — service registration +- `Areas/Identity/Pages/Account/Login.cshtml.cs` and the `Risk` / `Filter` / `Log` pages — SDK calls +- `Pages/Shared/_Layout.cshtml` — client-side Castle +- `appsettings.json` — your Castle API secret and App ID -## The integration +### Run -The example application applies the steps described for the [Castle Baseline Integration](https://castle.io/docs/baseline), with the addition of [secure requests](https://castle.io/docs/securing_requests). All Castle-related changes are marked with comments containing the word _Castle_ for easy searching, and affect the following files: +```bash +dotnet run --project src/CastleDemo/CastleDemo.csproj +``` + +Set your credentials in `appsettings.json` (or via environment / user secrets): -- `Startup.cs` Ioc -- `Areas/Identity/Pages/Account/Login.cshtml.cs` Castle SDK calls -- `Pages/Shared/_Layout.cshtml` Client-side Castle -- `appsettings.json` Your Castle API secret and App ID +```json +"Castle": { + "ApiSecret": "YOUR API SECRET", + "AppId": "YOUR APP ID" +} +``` -## Development testing +### Docker -# nuget add pathtonugetpackage.nupkg -source sourceDir +```bash +docker build -t castle-dotnet-example . +docker run -p 8080:8080 -e Castle__ApiSecret=YOUR_API_SECRET castle-dotnet-example +``` + +## .NET Framework sample (`src/CastleDemo.Framework`) + +A minimal `net48` console app that adapts a `System.Web` request to +`Castle.Context.FromHttpRequest(HttpRequestBase)` and sends a Risk request (with +`DoNotTrack` enabled so it runs without a real secret). It builds and runs on Windows: + +```bash +dotnet run --project src/CastleDemo.Framework/CastleDemo.Framework.csproj +``` -# dotnet add package Castle.Sdk -s sourceDir +This sample requires `Castle.Sdk` **2.4.0** or newer (the first version with a `net48` target). diff --git a/src/CastleDemo.Framework/CastleDemo.Framework.csproj b/src/CastleDemo.Framework/CastleDemo.Framework.csproj new file mode 100644 index 0000000..c2c6800 --- /dev/null +++ b/src/CastleDemo.Framework/CastleDemo.Framework.csproj @@ -0,0 +1,14 @@ + + + Exe + net48 + latest + CastleDemo.Framework + CastleDemo.Framework + + + + + + + diff --git a/src/CastleDemo.Framework/DemoHttpRequest.cs b/src/CastleDemo.Framework/DemoHttpRequest.cs new file mode 100644 index 0000000..789204b --- /dev/null +++ b/src/CastleDemo.Framework/DemoHttpRequest.cs @@ -0,0 +1,29 @@ +using System.Collections.Specialized; +using System.Web; + +namespace CastleDemo.Framework +{ + /// + /// A tiny stand-in so the console sample can build a + /// request context the same way an ASP.NET (System.Web) application would. + /// + internal sealed class DemoHttpRequest : HttpRequestBase + { + private readonly NameValueCollection _headers; + private readonly HttpCookieCollection _cookies; + private readonly string _userHostAddress; + + public DemoHttpRequest(NameValueCollection headers, HttpCookieCollection cookies, string userHostAddress) + { + _headers = headers; + _cookies = cookies; + _userHostAddress = userHostAddress; + } + + public override NameValueCollection Headers => _headers; + + public override HttpCookieCollection Cookies => _cookies; + + public override string UserHostAddress => _userHostAddress; + } +} diff --git a/src/CastleDemo.Framework/Program.cs b/src/CastleDemo.Framework/Program.cs new file mode 100644 index 0000000..d928bdc --- /dev/null +++ b/src/CastleDemo.Framework/Program.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Threading.Tasks; +using System.Web; +using Castle; +using Castle.Config; +using Castle.Messages.Requests; + +namespace CastleDemo.Framework +{ + /// + /// Minimal .NET Framework 4.8 sample. It exercises the System.Web code path of the + /// SDK — — which only builds and + /// runs on Windows. The Castle call uses DoNotTrack so the sample runs without a + /// real API secret or network access. + /// + internal static class Program + { + private static async Task Main() + { + var apiSecret = Environment.GetEnvironmentVariable("CASTLE_API_SECRET") ?? "YOUR API SECRET"; + + var client = new CastleClient(new CastleConfiguration(apiSecret) + { + DoNotTrack = true + }); + + var headers = new NameValueCollection + { + ["X-Castle-Client-ID"] = "the-client-id-from-castle-js", + ["X-Forwarded-For"] = "1.2.3.4" + }; + + // Adapt a System.Web request to the SDK's .NET Framework context overload. + HttpRequestBase request = new DemoHttpRequest(headers, new HttpCookieCollection(), "1.2.3.4"); + var context = Context.FromHttpRequest(request); + + var actionRequest = new ActionRequest + { + Event = "$login.succeeded", + UserId = "user-123", + UserTraits = new Dictionary + { + ["email"] = "user@example.com" + }, + Context = context + }; + + var verdict = await client.Risk(actionRequest); + + Console.WriteLine("Castle .NET Framework sample"); + Console.WriteLine($" Resolved client id: {context.ClientId}"); + Console.WriteLine($" Resolved ip: {context.Ip}"); + Console.WriteLine($" Verdict action: {verdict?.Action}"); + Console.WriteLine($" Failover: {verdict?.Failover}"); + } + } +} diff --git a/src/CastleDemo.sln b/src/CastleDemo.sln index d97f8a2..9b910bd 100644 --- a/src/CastleDemo.sln +++ b/src/CastleDemo.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 15.0.28307.438 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CastleDemo", "CastleDemo\CastleDemo.csproj", "{155A2766-3EE6-42EC-9A73-A835C190E89E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CastleDemo.Framework", "CastleDemo.Framework\CastleDemo.Framework.csproj", "{A9116005-AC8D-4F84-8375-0C41D9F5EF3D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {155A2766-3EE6-42EC-9A73-A835C190E89E}.Debug|Any CPU.Build.0 = Debug|Any CPU {155A2766-3EE6-42EC-9A73-A835C190E89E}.Release|Any CPU.ActiveCfg = Release|Any CPU {155A2766-3EE6-42EC-9A73-A835C190E89E}.Release|Any CPU.Build.0 = Release|Any CPU + {A9116005-AC8D-4F84-8375-0C41D9F5EF3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A9116005-AC8D-4F84-8375-0C41D9F5EF3D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A9116005-AC8D-4F84-8375-0C41D9F5EF3D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A9116005-AC8D-4F84-8375-0C41D9F5EF3D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/CastleDemo/Areas/Identity/Pages/Account/Login.cshtml.cs b/src/CastleDemo/Areas/Identity/Pages/Account/Login.cshtml.cs index ba0ff19..b1efe83 100644 --- a/src/CastleDemo/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/src/CastleDemo/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -11,7 +11,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; -using Microsoft.VisualStudio.Threading; namespace CastleDemo.Areas.Identity.Pages.Account { @@ -90,8 +89,8 @@ public async Task OnPostAsync(string returnUrl = null) { _logger.LogInformation("User logged in."); - // Castle Authenticate $login.succeeded - _castleClient.Authenticate(CreateCastleActionRequest("$login.succeeded")).Forget(); + // Castle Authenticate $login.succeeded (fire-and-forget) + _ = _castleClient.Authenticate(CreateCastleActionRequest("$login.succeeded")); return LocalRedirect(returnUrl); } @@ -106,8 +105,8 @@ public async Task OnPostAsync(string returnUrl = null) } else { - // Castle Track $login.failed - _castleClient.Track(CreateCastleActionRequest("$login.failed")).Forget(); + // Castle Track $login.failed (fire-and-forget) + _ = _castleClient.Track(CreateCastleActionRequest("$login.failed")); ModelState.AddModelError(string.Empty, "Invalid login attempt."); return Page(); diff --git a/src/CastleDemo/Areas/Identity/Pages/Filter/Filter.cshtml.cs b/src/CastleDemo/Areas/Identity/Pages/Filter/Filter.cshtml.cs index 1aa72cb..ccfd37d 100644 --- a/src/CastleDemo/Areas/Identity/Pages/Filter/Filter.cshtml.cs +++ b/src/CastleDemo/Areas/Identity/Pages/Filter/Filter.cshtml.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using Castle.Messages.Requests; using System.Collections.Generic; -using Microsoft.VisualStudio.Threading; using System.Threading.Tasks; using System; using Castle.Infrastructure.Exceptions; diff --git a/src/CastleDemo/Areas/Identity/Pages/Log/Log.cshtml.cs b/src/CastleDemo/Areas/Identity/Pages/Log/Log.cshtml.cs index 5da9010..03431e5 100644 --- a/src/CastleDemo/Areas/Identity/Pages/Log/Log.cshtml.cs +++ b/src/CastleDemo/Areas/Identity/Pages/Log/Log.cshtml.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using Castle.Messages.Requests; using System.Collections.Generic; -using Microsoft.VisualStudio.Threading; using System.Threading.Tasks; using System; diff --git a/src/CastleDemo/Areas/Identity/Pages/Risk/Risk.cshtml.cs b/src/CastleDemo/Areas/Identity/Pages/Risk/Risk.cshtml.cs index c29a1bf..1bb32e5 100644 --- a/src/CastleDemo/Areas/Identity/Pages/Risk/Risk.cshtml.cs +++ b/src/CastleDemo/Areas/Identity/Pages/Risk/Risk.cshtml.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using Castle.Messages.Requests; using System.Collections.Generic; -using Microsoft.VisualStudio.Threading; using System.Threading.Tasks; using System; using Castle.Infrastructure.Exceptions; diff --git a/src/CastleDemo/Areas/Identity/Pages/_ViewImports.cshtml b/src/CastleDemo/Areas/Identity/Pages/_ViewImports.cshtml index 71d0bf6..0138dd5 100644 --- a/src/CastleDemo/Areas/Identity/Pages/_ViewImports.cshtml +++ b/src/CastleDemo/Areas/Identity/Pages/_ViewImports.cshtml @@ -1,5 +1,4 @@ @using Microsoft.AspNetCore.Identity @using CastleDemo.Areas.Identity -@using Microsoft.AspNetCore.Identity @namespace CastleDemo.Areas.Identity.Pages @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/CastleDemo/CastleDemo.csproj b/src/CastleDemo/CastleDemo.csproj index fc8d3dc..0e64a0e 100644 --- a/src/CastleDemo/CastleDemo.csproj +++ b/src/CastleDemo/CastleDemo.csproj @@ -1,20 +1,15 @@ - net9.0 + net8.0 + enable + disable aspnet-CastleDemo-708B0CB0-0EAA-415A-87AA-60C6A98D774C - - - - - - - - - - - - + + + + + - \ No newline at end of file + diff --git a/src/CastleDemo/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs b/src/CastleDemo/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs deleted file mode 100644 index 0482a08..0000000 --- a/src/CastleDemo/Data/Migrations/00000000000000_CreateIdentitySchema.Designer.cs +++ /dev/null @@ -1,236 +0,0 @@ -// -using System; -using CastleDemo.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -namespace CastleDemo.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("00000000000000_CreateIdentitySchema")] - partial class CreateIdentitySchema - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.0-preview1") - .HasAnnotation("Relational:MaxIdentifierLength", 128) - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .ValueGeneratedOnAdd(); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken(); - - b.Property("Name") - .HasMaxLength(256); - - b.Property("NormalizedName") - .HasMaxLength(256); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - b.Property("ClaimType"); - - b.Property("ClaimValue"); - - b.Property("RoleId") - .IsRequired(); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd(); - - b.Property("AccessFailedCount"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken(); - - b.Property("Email") - .HasMaxLength(256); - - b.Property("EmailConfirmed"); - - b.Property("LockoutEnabled"); - - b.Property("LockoutEnd"); - - b.Property("NormalizedEmail") - .HasMaxLength(256); - - b.Property("NormalizedUserName") - .HasMaxLength(256); - - b.Property("PasswordHash"); - - b.Property("PhoneNumber"); - - b.Property("PhoneNumberConfirmed"); - - b.Property("SecurityStamp"); - - b.Property("TwoFactorEnabled"); - - b.Property("UserName") - .HasMaxLength(256); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - b.Property("ClaimType"); - - b.Property("ClaimValue"); - - b.Property("UserId") - .IsRequired(); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasMaxLength(128); - - b.Property("ProviderKey") - .HasMaxLength(128); - - b.Property("ProviderDisplayName"); - - b.Property("UserId") - .IsRequired(); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId"); - - b.Property("RoleId"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId"); - - b.Property("LoginProvider") - .HasMaxLength(128); - - b.Property("Name") - .HasMaxLength(128); - - b.Property("Value"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/CastleDemo/Data/Migrations/00000000000000_CreateIdentitySchema.cs b/src/CastleDemo/Data/Migrations/00000000000000_CreateIdentitySchema.cs deleted file mode 100644 index e6b3f84..0000000 --- a/src/CastleDemo/Data/Migrations/00000000000000_CreateIdentitySchema.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace CastleDemo.Data.Migrations -{ - public partial class CreateIdentitySchema : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "AspNetRoles", - columns: table => new - { - Id = table.Column(nullable: false), - Name = table.Column(maxLength: 256, nullable: true), - NormalizedName = table.Column(maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoles", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetUsers", - columns: table => new - { - Id = table.Column(nullable: false), - UserName = table.Column(maxLength: 256, nullable: true), - NormalizedUserName = table.Column(maxLength: 256, nullable: true), - Email = table.Column(maxLength: 256, nullable: true), - NormalizedEmail = table.Column(maxLength: 256, nullable: true), - EmailConfirmed = table.Column(nullable: false), - PasswordHash = table.Column(nullable: true), - SecurityStamp = table.Column(nullable: true), - ConcurrencyStamp = table.Column(nullable: true), - PhoneNumber = table.Column(nullable: true), - PhoneNumberConfirmed = table.Column(nullable: false), - TwoFactorEnabled = table.Column(nullable: false), - LockoutEnd = table.Column(nullable: true), - LockoutEnabled = table.Column(nullable: false), - AccessFailedCount = table.Column(nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUsers", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetRoleClaims", - columns: table => new - { - Id = table.Column(nullable: false) - .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), - RoleId = table.Column(nullable: false), - ClaimType = table.Column(nullable: true), - ClaimValue = table.Column(nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserClaims", - columns: table => new - { - Id = table.Column(nullable: false) - .Annotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn), - UserId = table.Column(nullable: false), - ClaimType = table.Column(nullable: true), - ClaimValue = table.Column(nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetUserClaims_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserLogins", - columns: table => new - { - LoginProvider = table.Column(maxLength: 128, nullable: false), - ProviderKey = table.Column(maxLength: 128, nullable: false), - ProviderDisplayName = table.Column(nullable: true), - UserId = table.Column(nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); - table.ForeignKey( - name: "FK_AspNetUserLogins_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserRoles", - columns: table => new - { - UserId = table.Column(nullable: false), - RoleId = table.Column(nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserTokens", - columns: table => new - { - UserId = table.Column(nullable: false), - LoginProvider = table.Column(maxLength: 128, nullable: false), - Name = table.Column(maxLength: 128, nullable: false), - Value = table.Column(nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); - table.ForeignKey( - name: "FK_AspNetUserTokens_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_AspNetRoleClaims_RoleId", - table: "AspNetRoleClaims", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "RoleNameIndex", - table: "AspNetRoles", - column: "NormalizedName", - unique: true, - filter: "[NormalizedName] IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserClaims_UserId", - table: "AspNetUserClaims", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserLogins_UserId", - table: "AspNetUserLogins", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserRoles_RoleId", - table: "AspNetUserRoles", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "EmailIndex", - table: "AspNetUsers", - column: "NormalizedEmail"); - - migrationBuilder.CreateIndex( - name: "UserNameIndex", - table: "AspNetUsers", - column: "NormalizedUserName", - unique: true, - filter: "[NormalizedUserName] IS NOT NULL"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AspNetRoleClaims"); - - migrationBuilder.DropTable( - name: "AspNetUserClaims"); - - migrationBuilder.DropTable( - name: "AspNetUserLogins"); - - migrationBuilder.DropTable( - name: "AspNetUserRoles"); - - migrationBuilder.DropTable( - name: "AspNetUserTokens"); - - migrationBuilder.DropTable( - name: "AspNetRoles"); - - migrationBuilder.DropTable( - name: "AspNetUsers"); - } - } -} diff --git a/src/CastleDemo/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/CastleDemo/Data/Migrations/ApplicationDbContextModelSnapshot.cs deleted file mode 100644 index ca45eaf..0000000 --- a/src/CastleDemo/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ /dev/null @@ -1,234 +0,0 @@ -// -using System; -using CastleDemo.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -namespace CastleDemo.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - partial class ApplicationDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "2.2.0-preview1") - .HasAnnotation("Relational:MaxIdentifierLength", 128) - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .ValueGeneratedOnAdd(); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken(); - - b.Property("Name") - .HasMaxLength(256); - - b.Property("NormalizedName") - .HasMaxLength(256); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - b.Property("ClaimType"); - - b.Property("ClaimValue"); - - b.Property("RoleId") - .IsRequired(); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd(); - - b.Property("AccessFailedCount"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken(); - - b.Property("Email") - .HasMaxLength(256); - - b.Property("EmailConfirmed"); - - b.Property("LockoutEnabled"); - - b.Property("LockoutEnd"); - - b.Property("NormalizedEmail") - .HasMaxLength(256); - - b.Property("NormalizedUserName") - .HasMaxLength(256); - - b.Property("PasswordHash"); - - b.Property("PhoneNumber"); - - b.Property("PhoneNumberConfirmed"); - - b.Property("SecurityStamp"); - - b.Property("TwoFactorEnabled"); - - b.Property("UserName") - .HasMaxLength(256); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); - - b.Property("ClaimType"); - - b.Property("ClaimValue"); - - b.Property("UserId") - .IsRequired(); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasMaxLength(128); - - b.Property("ProviderKey") - .HasMaxLength(128); - - b.Property("ProviderDisplayName"); - - b.Property("UserId") - .IsRequired(); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId"); - - b.Property("RoleId"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId"); - - b.Property("LoginProvider") - .HasMaxLength(128); - - b.Property("Name") - .HasMaxLength(128); - - b.Property("Value"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade); - - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/CastleDemo/Demos/DemoCatalog.cs b/src/CastleDemo/Demos/DemoCatalog.cs new file mode 100644 index 0000000..eea7958 --- /dev/null +++ b/src/CastleDemo/Demos/DemoCatalog.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; + +namespace CastleDemo.Demos +{ + /// + /// A single Castle demo scenario surfaced on the home page. + /// + public class DemoInfo + { + public string Title { get; set; } + + /// The Castle API used by the demo (Risk, Filter, Log, ...). + public string Api { get; set; } + + /// The Castle event/type the demo sends. + public string Event { get; set; } + + public string Blurb { get; set; } + + /// Razor Page path used with asp-page. + public string Page { get; set; } + + /// Razor Page area used with asp-area (empty for the root area). + public string Area { get; set; } + } + + /// + /// Central registry of the demos shipped with this sample so they can be + /// listed in one place and linked from the home page. + /// + public static class DemoCatalog + { + public static readonly IReadOnlyList Demos = new List + { + new DemoInfo + { + Title = "Login", + Api = "Authenticate / Track", + Event = "$login.succeeded / $login.failed", + Blurb = "Sign in to send an Authenticate request on success and a Track request on failure.", + Area = "Identity", + Page = "/Account/Login" + }, + new DemoInfo + { + Title = "Risk", + Api = "Risk", + Event = "$profile_update", + Blurb = "Send a synchronous Risk request and inspect the verdict.", + Area = "Identity", + Page = "/Risk/Risk" + }, + new DemoInfo + { + Title = "Filter", + Api = "Filter", + Event = "$custom", + Blurb = "Evaluate a pre-authentication event such as a custom abuse signal.", + Area = "Identity", + Page = "/Filter/Filter" + }, + new DemoInfo + { + Title = "Log", + Api = "Log", + Event = "$challenge", + Blurb = "Record an event for monitoring without affecting a verdict.", + Area = "Identity", + Page = "/Log/Log" + } + }; + } +} diff --git a/src/CastleDemo/Pages/Index.cshtml b/src/CastleDemo/Pages/Index.cshtml index b5f0c15..80bbf2a 100644 --- a/src/CastleDemo/Pages/Index.cshtml +++ b/src/CastleDemo/Pages/Index.cshtml @@ -1,10 +1,26 @@ @page @model IndexModel @{ - ViewData["Title"] = "Home page"; + ViewData["Title"] = "Castle .NET demos"; }
-

Welcome

-

Learn about building Web apps with ASP.NET Core.

+

Castle .NET SDK demo

+

Each card below triggers a Castle API call so you can see the SDK in action.

+
+ +
+ @foreach (var demo in Model.Demos) + { +
+
+
+
@demo.Title
+
@demo.Api — @demo.Event
+

@demo.Blurb

+ Open demo +
+
+
+ }
diff --git a/src/CastleDemo/Pages/Index.cshtml.cs b/src/CastleDemo/Pages/Index.cshtml.cs index a1562c7..fec9cd2 100644 --- a/src/CastleDemo/Pages/Index.cshtml.cs +++ b/src/CastleDemo/Pages/Index.cshtml.cs @@ -1,17 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; +using System.Collections.Generic; +using CastleDemo.Demos; using Microsoft.AspNetCore.Mvc.RazorPages; namespace CastleDemo.Pages { public class IndexModel : PageModel { + public IReadOnlyList Demos => DemoCatalog.Demos; + public void OnGet() { - } } } diff --git a/src/CastleDemo/Program.cs b/src/CastleDemo/Program.cs index bb8553a..e9e89d6 100644 --- a/src/CastleDemo/Program.cs +++ b/src/CastleDemo/Program.cs @@ -1,24 +1,50 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace CastleDemo +using Castle; +using Castle.Config; +using CastleDemo.Data; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(options => +{ + options.CheckConsentNeeded = _ => true; + options.MinimumSameSitePolicy = SameSiteMode.None; +}); + +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("CastleDemo")); + +builder.Services.AddDefaultIdentity() + .AddEntityFrameworkStores(); + +builder.Services.AddRazorPages(); + +// Castle: register a single client from the configured API secret. +builder.Services.AddSingleton(new CastleClient( + new CastleConfiguration(builder.Configuration["Castle:ApiSecret"]))); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} +else { - public class Program - { - public static void Main(string[] args) - { - CreateWebHostBuilder(args).Build().Run(); - } - - public static IWebHostBuilder CreateWebHostBuilder(string[] args) => - WebHost.CreateDefaultBuilder(args) - .UseStartup(); - } + app.UseExceptionHandler("/Error"); + app.UseHsts(); } + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseCookiePolicy(); + +app.UseRouting(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapRazorPages(); + +app.Run(); diff --git a/src/CastleDemo/ScaffoldingReadme.txt b/src/CastleDemo/ScaffoldingReadme.txt deleted file mode 100644 index 980e996..0000000 --- a/src/CastleDemo/ScaffoldingReadme.txt +++ /dev/null @@ -1,18 +0,0 @@ -Support for ASP.NET Core Identity was added to your project -- The code for adding Identity to your project was generated under Areas/Identity. - -Configuration of the Identity related services can be found in the Areas/Identity/IdentityHostingStartup.cs file. - - -The generated UI requires support for static files. To add static files to your app: -1. Call app.UseStaticFiles() from your Configure method - -To use ASP.NET Core Identity you also need to enable authentication. To authentication to your app: -1. Call app.UseAuthentication() from your Configure method (after static files) - -The generated UI requires MVC. To add MVC to your app: -1. Call services.AddMvc() from your ConfigureServices method -2. Call app.UseMvc() from your Configure method (after authentication) - -Apps that use ASP.NET Core Identity should also use HTTPS. To enable HTTPS see https://go.microsoft.com/fwlink/?linkid=848054. - diff --git a/src/CastleDemo/Startup.cs b/src/CastleDemo/Startup.cs deleted file mode 100644 index e18d393..0000000 --- a/src/CastleDemo/Startup.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Castle; -using Castle.Config; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; -using CastleDemo.Data; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace CastleDemo -{ - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - public void ConfigureServices(IServiceCollection services) - { - services.Configure(options => - { - options.CheckConsentNeeded = context => true; - options.MinimumSameSitePolicy = SameSiteMode.None; - }); - - services.AddDbContext(options => - options.UseInMemoryDatabase("CastleDemo") - ); - services.AddIdentityCore() - .AddDefaultUI() - .AddEntityFrameworkStores(); - - services.AddRazorPages(); - - services.AddSingleton(new CastleClient(new CastleConfiguration(Configuration["Castle:ApiSecret"]))); - } - - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - app.UseMigrationsEndPoint(); - } - else - { - app.UseExceptionHandler("/Error"); - app.UseHsts(); - } - - app.UseHttpsRedirection(); - app.UseStaticFiles(); - app.UseCookiePolicy(); - - app.UseRouting(); - - app.UseAuthentication(); - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapRazorPages(); - }); - } - } -} From e50736a054de1a276ee207142b821265925765d5 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Wed, 10 Jun 2026 16:27:54 +0200 Subject: [PATCH 2/2] Demonstrate the 3.0.0 SDK: add Lists/Privacy/Events/Webhook demos, move Login off legacy endpoints Adds demo pages for the Lists, Privacy and Events APIs and webhook signature verification, and registers them in the demo catalog. The Login demo now sends Risk on success and Log on failure, and the .NET Framework sample no longer reads the client id. Bumps the ASP.NET Core sample to Castle.Sdk 3.0.0. --- README.md | 6 +- src/CastleDemo.Framework/Program.cs | 2 - .../Identity/Pages/Account/Login.cshtml.cs | 8 +-- .../Areas/Identity/Pages/Events/Events.cshtml | 21 ++++++ .../Identity/Pages/Events/Events.cshtml.cs | 53 ++++++++++++++ .../Identity/Pages/Events/_ViewImports.cshtml | 1 + .../Areas/Identity/Pages/Lists/Lists.cshtml | 21 ++++++ .../Identity/Pages/Lists/Lists.cshtml.cs | 61 ++++++++++++++++ .../Identity/Pages/Lists/_ViewImports.cshtml | 1 + .../Identity/Pages/Privacy/Privacy.cshtml | 21 ++++++ .../Identity/Pages/Privacy/Privacy.cshtml.cs | 50 +++++++++++++ .../Pages/Privacy/_ViewImports.cshtml | 1 + .../Identity/Pages/Webhook/Webhook.cshtml | 21 ++++++ .../Identity/Pages/Webhook/Webhook.cshtml.cs | 70 +++++++++++++++++++ .../Pages/Webhook/_ViewImports.cshtml | 1 + src/CastleDemo/CastleDemo.csproj | 2 +- src/CastleDemo/Demos/DemoCatalog.cs | 40 ++++++++++- 17 files changed, 369 insertions(+), 11 deletions(-) create mode 100644 src/CastleDemo/Areas/Identity/Pages/Events/Events.cshtml create mode 100644 src/CastleDemo/Areas/Identity/Pages/Events/Events.cshtml.cs create mode 100644 src/CastleDemo/Areas/Identity/Pages/Events/_ViewImports.cshtml create mode 100644 src/CastleDemo/Areas/Identity/Pages/Lists/Lists.cshtml create mode 100644 src/CastleDemo/Areas/Identity/Pages/Lists/Lists.cshtml.cs create mode 100644 src/CastleDemo/Areas/Identity/Pages/Lists/_ViewImports.cshtml create mode 100644 src/CastleDemo/Areas/Identity/Pages/Privacy/Privacy.cshtml create mode 100644 src/CastleDemo/Areas/Identity/Pages/Privacy/Privacy.cshtml.cs create mode 100644 src/CastleDemo/Areas/Identity/Pages/Privacy/_ViewImports.cshtml create mode 100644 src/CastleDemo/Areas/Identity/Pages/Webhook/Webhook.cshtml create mode 100644 src/CastleDemo/Areas/Identity/Pages/Webhook/Webhook.cshtml.cs create mode 100644 src/CastleDemo/Areas/Identity/Pages/Webhook/_ViewImports.cshtml diff --git a/README.md b/README.md index ddc14bc..2fd9819 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,10 @@ builder.Services.AddDbContext(options => options.UseInMemoryDatabase("CastleDemo")); ``` -- Fire-and-forget Castle calls (e.g. **Track** / **Authenticate** in monitor mode) use a - discard (`_ = client.Track(...)`) rather than an external dependency. +- Fire-and-forget Castle calls (e.g. **Risk** / **Log** in monitor mode) use a + discard (`_ = client.Log(...)`) rather than an external dependency. +- Demos cover **Risk**, **Filter**, **Log**, **Lists**, **Privacy**, **Events** and + **webhook verification** (see `Demos/DemoCatalog.cs`). - Client-side fingerprinting and secure mode are wired up in `Pages/Shared/_Layout.cshtml`. All Castle-related changes are marked with comments containing the word _Castle_ for easy diff --git a/src/CastleDemo.Framework/Program.cs b/src/CastleDemo.Framework/Program.cs index d928bdc..8f0effd 100644 --- a/src/CastleDemo.Framework/Program.cs +++ b/src/CastleDemo.Framework/Program.cs @@ -28,7 +28,6 @@ private static async Task Main() var headers = new NameValueCollection { - ["X-Castle-Client-ID"] = "the-client-id-from-castle-js", ["X-Forwarded-For"] = "1.2.3.4" }; @@ -50,7 +49,6 @@ private static async Task Main() var verdict = await client.Risk(actionRequest); Console.WriteLine("Castle .NET Framework sample"); - Console.WriteLine($" Resolved client id: {context.ClientId}"); Console.WriteLine($" Resolved ip: {context.Ip}"); Console.WriteLine($" Verdict action: {verdict?.Action}"); Console.WriteLine($" Failover: {verdict?.Failover}"); diff --git a/src/CastleDemo/Areas/Identity/Pages/Account/Login.cshtml.cs b/src/CastleDemo/Areas/Identity/Pages/Account/Login.cshtml.cs index b1efe83..be11797 100644 --- a/src/CastleDemo/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/src/CastleDemo/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -89,8 +89,8 @@ public async Task OnPostAsync(string returnUrl = null) { _logger.LogInformation("User logged in."); - // Castle Authenticate $login.succeeded (fire-and-forget) - _ = _castleClient.Authenticate(CreateCastleActionRequest("$login.succeeded")); + // Castle Risk $login.succeeded (fire-and-forget) + _ = _castleClient.Risk(CreateCastleActionRequest("$login.succeeded")); return LocalRedirect(returnUrl); } @@ -105,8 +105,8 @@ public async Task OnPostAsync(string returnUrl = null) } else { - // Castle Track $login.failed (fire-and-forget) - _ = _castleClient.Track(CreateCastleActionRequest("$login.failed")); + // Castle Log $login.failed (fire-and-forget) + _ = _castleClient.Log(CreateCastleActionRequest("$login.failed")); ModelState.AddModelError(string.Empty, "Invalid login attempt."); return Page(); diff --git a/src/CastleDemo/Areas/Identity/Pages/Events/Events.cshtml b/src/CastleDemo/Areas/Identity/Pages/Events/Events.cshtml new file mode 100644 index 0000000..633181e --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Events/Events.cshtml @@ -0,0 +1,21 @@ +@page +@model EventsModel + +@{ + ViewData["Title"] = "Events"; +} + +

@ViewData["Title"]

+ +
+
+

Fetch the events schema and run a query against event data (enterprise).

+
+ +
+ @if (!string.IsNullOrEmpty(Model.Status)) + { +
@Model.Status
+ } +
+
diff --git a/src/CastleDemo/Areas/Identity/Pages/Events/Events.cshtml.cs b/src/CastleDemo/Areas/Identity/Pages/Events/Events.cshtml.cs new file mode 100644 index 0000000..4b2a68c --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Events/Events.cshtml.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Castle; +using Castle.Messages.Requests; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace CastleDemo.Areas.Identity.Pages.Events +{ + [AllowAnonymous] + public class EventsModel : PageModel + { + private readonly CastleClient _castleClient; + + public EventsModel(CastleClient castleClient) + { + _castleClient = castleClient; + } + + public string Status { get; private set; } + + public void OnGet() + { + } + + // Fetches the events schema and runs a query for $login events. + public async Task OnPostAsync() + { + try + { + var schema = await _castleClient.EventsSchema(); + + var events = await _castleClient.QueryEvents(new EventsQueryRequest + { + Filters = new List + { + new QueryFilter { Field = "name", Op = "$eq", Value = "$login" } + } + }); + + Status = $"Schema fields: {schema.Fields?.Count ?? 0}; query returned {events.TotalCount} event(s)."; + } + catch (Exception e) + { + Status = $"Request failed: {e.Message}"; + } + + return Page(); + } + } +} diff --git a/src/CastleDemo/Areas/Identity/Pages/Events/_ViewImports.cshtml b/src/CastleDemo/Areas/Identity/Pages/Events/_ViewImports.cshtml new file mode 100644 index 0000000..9b9610a --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Events/_ViewImports.cshtml @@ -0,0 +1 @@ +@using CastleDemo.Areas.Identity.Pages.Events diff --git a/src/CastleDemo/Areas/Identity/Pages/Lists/Lists.cshtml b/src/CastleDemo/Areas/Identity/Pages/Lists/Lists.cshtml new file mode 100644 index 0000000..3f22534 --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Lists/Lists.cshtml @@ -0,0 +1,21 @@ +@page +@model ListsModel + +@{ + ViewData["Title"] = "Lists"; +} + +

@ViewData["Title"]

+ +
+
+

Create a list, add an item, query it and delete it again via the Lists API.

+
+ +
+ @if (!string.IsNullOrEmpty(Model.Status)) + { +
@Model.Status
+ } +
+
diff --git a/src/CastleDemo/Areas/Identity/Pages/Lists/Lists.cshtml.cs b/src/CastleDemo/Areas/Identity/Pages/Lists/Lists.cshtml.cs new file mode 100644 index 0000000..73f05a4 --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Lists/Lists.cshtml.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Castle; +using Castle.Messages.Requests; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace CastleDemo.Areas.Identity.Pages.Lists +{ + [AllowAnonymous] + public class ListsModel : PageModel + { + private readonly CastleClient _castleClient; + + public ListsModel(CastleClient castleClient) + { + _castleClient = castleClient; + } + + public string Status { get; private set; } + + public void OnGet() + { + } + + // Walks through the Lists + List items API: create a list, add an item, + // query the items and remove the list again. + public async Task OnPostAsync() + { + try + { + var list = await _castleClient.CreateList(new CreateListRequest + { + Name = "Demo blocklist", + Color = "$red", + PrimaryField = "user.email" + }); + + var item = await _castleClient.CreateListItem(list.Id, new CreateListItemRequest + { + PrimaryValue = "demo@example.com", + Author = new ListItemAuthor { Type = "$user", Identifier = "user:demo" } + }); + + var items = await _castleClient.QueryListItems(list.Id, new SearchQuery()); + + await _castleClient.DeleteList(list.Id); + + Status = $"Created list {list.Id}, added item {item.Id}, queried {items.Count} item(s), deleted the list."; + } + catch (Exception e) + { + Status = $"Request failed: {e.Message}"; + } + + return Page(); + } + } +} diff --git a/src/CastleDemo/Areas/Identity/Pages/Lists/_ViewImports.cshtml b/src/CastleDemo/Areas/Identity/Pages/Lists/_ViewImports.cshtml new file mode 100644 index 0000000..184aea7 --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Lists/_ViewImports.cshtml @@ -0,0 +1 @@ +@using CastleDemo.Areas.Identity.Pages.Lists diff --git a/src/CastleDemo/Areas/Identity/Pages/Privacy/Privacy.cshtml b/src/CastleDemo/Areas/Identity/Pages/Privacy/Privacy.cshtml new file mode 100644 index 0000000..7c0040e --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Privacy/Privacy.cshtml @@ -0,0 +1,21 @@ +@page +@model PrivacyModel + +@{ + ViewData["Title"] = "Privacy"; +} + +

@ViewData["Title"]

+ +
+
+

Request and then delete the data Castle stores for a user.

+
+ +
+ @if (!string.IsNullOrEmpty(Model.Status)) + { +
@Model.Status
+ } +
+
diff --git a/src/CastleDemo/Areas/Identity/Pages/Privacy/Privacy.cshtml.cs b/src/CastleDemo/Areas/Identity/Pages/Privacy/Privacy.cshtml.cs new file mode 100644 index 0000000..3dcd178 --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Privacy/Privacy.cshtml.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading.Tasks; +using Castle; +using Castle.Messages.Requests; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace CastleDemo.Areas.Identity.Pages.Privacy +{ + [AllowAnonymous] + public class PrivacyModel : PageModel + { + private readonly CastleClient _castleClient; + + public PrivacyModel(CastleClient castleClient) + { + _castleClient = castleClient; + } + + public string Status { get; private set; } + + public void OnGet() + { + } + + public async Task OnPostAsync() + { + var request = new PrivacyRequest + { + Identifier = "demo@example.com", + IdentifierType = "$email" + }; + + try + { + await _castleClient.RequestUserData(request); + await _castleClient.DeleteUserData(request); + + Status = "Submitted a data request and a data deletion for demo@example.com."; + } + catch (Exception e) + { + Status = $"Request failed: {e.Message}"; + } + + return Page(); + } + } +} diff --git a/src/CastleDemo/Areas/Identity/Pages/Privacy/_ViewImports.cshtml b/src/CastleDemo/Areas/Identity/Pages/Privacy/_ViewImports.cshtml new file mode 100644 index 0000000..d9a6a4c --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Privacy/_ViewImports.cshtml @@ -0,0 +1 @@ +@using CastleDemo.Areas.Identity.Pages.Privacy diff --git a/src/CastleDemo/Areas/Identity/Pages/Webhook/Webhook.cshtml b/src/CastleDemo/Areas/Identity/Pages/Webhook/Webhook.cshtml new file mode 100644 index 0000000..1903205 --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Webhook/Webhook.cshtml @@ -0,0 +1,21 @@ +@page +@model WebhookModel + +@{ + ViewData["Title"] = "Webhooks"; +} + +

@ViewData["Title"]

+ +
+
+

Verify an incoming webhook against the X-Castle-Signature header.

+
+ +
+ @if (!string.IsNullOrEmpty(Model.Status)) + { +
@Model.Status
+ } +
+
diff --git a/src/CastleDemo/Areas/Identity/Pages/Webhook/Webhook.cshtml.cs b/src/CastleDemo/Areas/Identity/Pages/Webhook/Webhook.cshtml.cs new file mode 100644 index 0000000..03d1d5a --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Webhook/Webhook.cshtml.cs @@ -0,0 +1,70 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using Castle.Infrastructure.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Configuration; + +namespace CastleDemo.Areas.Identity.Pages.Webhook +{ + [AllowAnonymous] + public class WebhookModel : PageModel + { + private readonly string _apiSecret; + + public WebhookModel(IConfiguration configuration) + { + _apiSecret = configuration["Castle:ApiSecret"]; + } + + public string Status { get; private set; } + + public void OnGet() + { + } + + // Demonstrates verifying an incoming webhook against the X-Castle-Signature + // header. We sign a sample payload ourselves to mimic Castle's signature. + public IActionResult OnPost() + { + const string body = "{\"type\":\"$review.opened\",\"data\":{}}"; + + var signature = Sign(_apiSecret, body); + + try + { + Castle.Webhook.Verify(body, signature, _apiSecret); + + var tampered = false; + try + { + Castle.Webhook.Verify(body, "invalid-signature", _apiSecret); + } + catch (CastleWebhookVerificationException) + { + tampered = true; + } + + Status = tampered + ? "Valid signature accepted; tampered signature rejected." + : "Valid signature accepted."; + } + catch (CastleWebhookVerificationException e) + { + Status = $"Verification failed: {e.Message}"; + } + + return Page(); + } + + private static string Sign(string secret, string body) + { + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret ?? ""))) + { + return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(body))); + } + } + } +} diff --git a/src/CastleDemo/Areas/Identity/Pages/Webhook/_ViewImports.cshtml b/src/CastleDemo/Areas/Identity/Pages/Webhook/_ViewImports.cshtml new file mode 100644 index 0000000..d3a143c --- /dev/null +++ b/src/CastleDemo/Areas/Identity/Pages/Webhook/_ViewImports.cshtml @@ -0,0 +1 @@ +@using CastleDemo.Areas.Identity.Pages.Webhook diff --git a/src/CastleDemo/CastleDemo.csproj b/src/CastleDemo/CastleDemo.csproj index 0e64a0e..cbd914a 100644 --- a/src/CastleDemo/CastleDemo.csproj +++ b/src/CastleDemo/CastleDemo.csproj @@ -6,7 +6,7 @@ aspnet-CastleDemo-708B0CB0-0EAA-415A-87AA-60C6A98D774C - + diff --git a/src/CastleDemo/Demos/DemoCatalog.cs b/src/CastleDemo/Demos/DemoCatalog.cs index eea7958..7e03a14 100644 --- a/src/CastleDemo/Demos/DemoCatalog.cs +++ b/src/CastleDemo/Demos/DemoCatalog.cs @@ -35,9 +35,9 @@ public static class DemoCatalog new DemoInfo { Title = "Login", - Api = "Authenticate / Track", + Api = "Risk / Log", Event = "$login.succeeded / $login.failed", - Blurb = "Sign in to send an Authenticate request on success and a Track request on failure.", + Blurb = "Sign in to send a Risk request on success and a Log request on failure.", Area = "Identity", Page = "/Account/Login" }, @@ -67,6 +67,42 @@ public static class DemoCatalog Blurb = "Record an event for monitoring without affecting a verdict.", Area = "Identity", Page = "/Log/Log" + }, + new DemoInfo + { + Title = "Lists", + Api = "Lists", + Event = "n/a", + Blurb = "Create a list, add an item, query it and clean up via the Lists API.", + Area = "Identity", + Page = "/Lists/Lists" + }, + new DemoInfo + { + Title = "Privacy", + Api = "Privacy", + Event = "n/a", + Blurb = "Request and delete the data Castle stores for a user.", + Area = "Identity", + Page = "/Privacy/Privacy" + }, + new DemoInfo + { + Title = "Events", + Api = "Events", + Event = "n/a", + Blurb = "Fetch the events schema and run a query against event data.", + Area = "Identity", + Page = "/Events/Events" + }, + new DemoInfo + { + Title = "Webhooks", + Api = "Webhook", + Event = "n/a", + Blurb = "Verify an incoming webhook against the X-Castle-Signature header.", + Area = "Identity", + Page = "/Webhook/Webhook" } }; }