From 474a892a8273cfb6cf9c93eb3332fc69456a8122 Mon Sep 17 00:00:00 2001 From: "vianney.jourdy" Date: Wed, 21 May 2025 14:58:10 +0200 Subject: [PATCH] :spark: Initialization IdentityServer (Duende) --- OptifitWebServices.sln | 7 + src/IdentitySvc/Config.cs | 52 ++++ src/IdentitySvc/Data/ApplicationDbContext.cs | 21 ++ .../20240123193529_Users.Designer.cs | 268 ++++++++++++++++++ .../Data/Migrations/20240123193529_Users.cs | 222 +++++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 265 +++++++++++++++++ src/IdentitySvc/HostingExtensions.cs | 73 +++++ src/IdentitySvc/IdentitySvc.csproj | 21 ++ src/IdentitySvc/Models/ApplicationUser.cs | 12 + .../Pages/Account/AccessDenied.cshtml | 10 + .../Pages/Account/AccessDenied.cshtml.cs | 13 + .../Pages/Account/Login/Index.cshtml | 89 ++++++ .../Pages/Account/Login/Index.cshtml.cs | 216 ++++++++++++++ .../Pages/Account/Login/InputModel.cs | 17 ++ .../Pages/Account/Login/LoginOptions.cs | 12 + .../Pages/Account/Login/ViewModel.cs | 28 ++ .../Pages/Account/Logout/Index.cshtml | 17 ++ .../Pages/Account/Logout/Index.cshtml.cs | 104 +++++++ .../Pages/Account/Logout/LoggedOut.cshtml | 30 ++ .../Pages/Account/Logout/LoggedOut.cshtml.cs | 36 +++ .../Account/Logout/LoggedOutViewModel.cs | 15 + .../Pages/Account/Logout/LogoutOptions.cs | 11 + src/IdentitySvc/Pages/Ciba/All.cshtml | 48 ++++ src/IdentitySvc/Pages/Ciba/All.cshtml.cs | 28 ++ src/IdentitySvc/Pages/Ciba/Consent.cshtml | 98 +++++++ src/IdentitySvc/Pages/Ciba/Consent.cshtml.cs | 228 +++++++++++++++ src/IdentitySvc/Pages/Ciba/ConsentOptions.cs | 14 + src/IdentitySvc/Pages/Ciba/Index.cshtml | 30 ++ src/IdentitySvc/Pages/Ciba/Index.cshtml.cs | 42 +++ src/IdentitySvc/Pages/Ciba/InputModel.cs | 12 + src/IdentitySvc/Pages/Ciba/ViewModel.cs | 34 +++ .../Pages/Ciba/_ScopeListItem.cshtml | 47 +++ .../Pages/Consent/ConsentOptions.cs | 14 + src/IdentitySvc/Pages/Consent/Index.cshtml | 107 +++++++ src/IdentitySvc/Pages/Consent/Index.cshtml.cs | 236 +++++++++++++++ src/IdentitySvc/Pages/Consent/InputModel.cs | 13 + src/IdentitySvc/Pages/Consent/ViewModel.cs | 33 +++ .../Pages/Consent/_ScopeListItem.cshtml | 47 +++ src/IdentitySvc/Pages/Device/DeviceOptions.cs | 15 + src/IdentitySvc/Pages/Device/Index.cshtml | 141 +++++++++ src/IdentitySvc/Pages/Device/Index.cshtml.cs | 220 ++++++++++++++ src/IdentitySvc/Pages/Device/InputModel.cs | 14 + src/IdentitySvc/Pages/Device/Success.cshtml | 12 + .../Pages/Device/Success.cshtml.cs | 16 ++ src/IdentitySvc/Pages/Device/ViewModel.cs | 25 ++ .../Pages/Device/_ScopeListItem.cshtml | 35 +++ .../Pages/Diagnostics/Index.cshtml | 67 +++++ .../Pages/Diagnostics/Index.cshtml.cs | 34 +++ .../Pages/Diagnostics/ViewModel.cs | 32 +++ src/IdentitySvc/Pages/Extensions.cs | 42 +++ .../Pages/ExternalLogin/Callback.cshtml | 19 ++ .../Pages/ExternalLogin/Callback.cshtml.cs | 203 +++++++++++++ .../Pages/ExternalLogin/Challenge.cshtml | 19 ++ .../Pages/ExternalLogin/Challenge.cshtml.cs | 48 ++++ src/IdentitySvc/Pages/Grants/Index.cshtml | 90 ++++++ src/IdentitySvc/Pages/Grants/Index.cshtml.cs | 82 ++++++ src/IdentitySvc/Pages/Grants/ViewModel.cs | 22 ++ src/IdentitySvc/Pages/Home/Error/Index.cshtml | 35 +++ .../Pages/Home/Error/Index.cshtml.cs | 40 +++ src/IdentitySvc/Pages/Home/Error/ViewModel.cs | 20 ++ .../Pages/IdentityServerSuppressions.cs | 22 ++ src/IdentitySvc/Pages/Index.cshtml | 46 +++ src/IdentitySvc/Pages/Index.cshtml.cs | 27 ++ src/IdentitySvc/Pages/Log.cs | 87 ++++++ src/IdentitySvc/Pages/Redirect/Index.cshtml | 14 + .../Pages/Redirect/Index.cshtml.cs | 25 ++ .../Pages/SecurityHeadersAttribute.cs | 56 ++++ .../Pages/ServerSideSessions/Index.cshtml | 147 ++++++++++ .../Pages/ServerSideSessions/Index.cshtml.cs | 67 +++++ src/IdentitySvc/Pages/Shared/_Layout.cshtml | 29 ++ src/IdentitySvc/Pages/Shared/_Nav.cshtml | 33 +++ .../Pages/Shared/_ValidationSummary.cshtml | 7 + src/IdentitySvc/Pages/Telemetry.cs | 142 ++++++++++ src/IdentitySvc/Pages/_ViewImports.cshtml | 2 + src/IdentitySvc/Pages/_ViewStart.cshtml | 3 + src/IdentitySvc/Program.cs | 43 +++ .../Properties/launchSettings.json | 12 + src/IdentitySvc/SeedData.cs | 87 ++++++ src/IdentitySvc/buildschema.bat | 3 + src/IdentitySvc/buildschema.sh | 5 + src/IdentitySvc/wwwroot/css/site.css | 39 +++ src/IdentitySvc/wwwroot/css/site.min.css | 1 + src/IdentitySvc/wwwroot/css/site.scss | 50 ++++ src/IdentitySvc/wwwroot/duende-logo.svg | 1 + src/IdentitySvc/wwwroot/favicon.ico | Bin 0 -> 15406 bytes src/IdentitySvc/wwwroot/js/signin-redirect.js | 1 + .../wwwroot/js/signout-redirect.js | 6 + src/Shared/Shared.csproj | 13 + 88 files changed, 4769 insertions(+) create mode 100644 src/IdentitySvc/Config.cs create mode 100644 src/IdentitySvc/Data/ApplicationDbContext.cs create mode 100644 src/IdentitySvc/Data/Migrations/20240123193529_Users.Designer.cs create mode 100644 src/IdentitySvc/Data/Migrations/20240123193529_Users.cs create mode 100644 src/IdentitySvc/Data/Migrations/ApplicationDbContextModelSnapshot.cs create mode 100644 src/IdentitySvc/HostingExtensions.cs create mode 100644 src/IdentitySvc/IdentitySvc.csproj create mode 100644 src/IdentitySvc/Models/ApplicationUser.cs create mode 100644 src/IdentitySvc/Pages/Account/AccessDenied.cshtml create mode 100644 src/IdentitySvc/Pages/Account/AccessDenied.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Account/Login/Index.cshtml create mode 100644 src/IdentitySvc/Pages/Account/Login/Index.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Account/Login/InputModel.cs create mode 100644 src/IdentitySvc/Pages/Account/Login/LoginOptions.cs create mode 100644 src/IdentitySvc/Pages/Account/Login/ViewModel.cs create mode 100644 src/IdentitySvc/Pages/Account/Logout/Index.cshtml create mode 100644 src/IdentitySvc/Pages/Account/Logout/Index.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Account/Logout/LoggedOut.cshtml create mode 100644 src/IdentitySvc/Pages/Account/Logout/LoggedOut.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Account/Logout/LoggedOutViewModel.cs create mode 100644 src/IdentitySvc/Pages/Account/Logout/LogoutOptions.cs create mode 100644 src/IdentitySvc/Pages/Ciba/All.cshtml create mode 100644 src/IdentitySvc/Pages/Ciba/All.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Ciba/Consent.cshtml create mode 100644 src/IdentitySvc/Pages/Ciba/Consent.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Ciba/ConsentOptions.cs create mode 100644 src/IdentitySvc/Pages/Ciba/Index.cshtml create mode 100644 src/IdentitySvc/Pages/Ciba/Index.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Ciba/InputModel.cs create mode 100644 src/IdentitySvc/Pages/Ciba/ViewModel.cs create mode 100644 src/IdentitySvc/Pages/Ciba/_ScopeListItem.cshtml create mode 100644 src/IdentitySvc/Pages/Consent/ConsentOptions.cs create mode 100644 src/IdentitySvc/Pages/Consent/Index.cshtml create mode 100644 src/IdentitySvc/Pages/Consent/Index.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Consent/InputModel.cs create mode 100644 src/IdentitySvc/Pages/Consent/ViewModel.cs create mode 100644 src/IdentitySvc/Pages/Consent/_ScopeListItem.cshtml create mode 100644 src/IdentitySvc/Pages/Device/DeviceOptions.cs create mode 100644 src/IdentitySvc/Pages/Device/Index.cshtml create mode 100644 src/IdentitySvc/Pages/Device/Index.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Device/InputModel.cs create mode 100644 src/IdentitySvc/Pages/Device/Success.cshtml create mode 100644 src/IdentitySvc/Pages/Device/Success.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Device/ViewModel.cs create mode 100644 src/IdentitySvc/Pages/Device/_ScopeListItem.cshtml create mode 100644 src/IdentitySvc/Pages/Diagnostics/Index.cshtml create mode 100644 src/IdentitySvc/Pages/Diagnostics/Index.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Diagnostics/ViewModel.cs create mode 100644 src/IdentitySvc/Pages/Extensions.cs create mode 100644 src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml create mode 100644 src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml.cs create mode 100644 src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml create mode 100644 src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Grants/Index.cshtml create mode 100644 src/IdentitySvc/Pages/Grants/Index.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Grants/ViewModel.cs create mode 100644 src/IdentitySvc/Pages/Home/Error/Index.cshtml create mode 100644 src/IdentitySvc/Pages/Home/Error/Index.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Home/Error/ViewModel.cs create mode 100644 src/IdentitySvc/Pages/IdentityServerSuppressions.cs create mode 100644 src/IdentitySvc/Pages/Index.cshtml create mode 100644 src/IdentitySvc/Pages/Index.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Log.cs create mode 100644 src/IdentitySvc/Pages/Redirect/Index.cshtml create mode 100644 src/IdentitySvc/Pages/Redirect/Index.cshtml.cs create mode 100644 src/IdentitySvc/Pages/SecurityHeadersAttribute.cs create mode 100644 src/IdentitySvc/Pages/ServerSideSessions/Index.cshtml create mode 100644 src/IdentitySvc/Pages/ServerSideSessions/Index.cshtml.cs create mode 100644 src/IdentitySvc/Pages/Shared/_Layout.cshtml create mode 100644 src/IdentitySvc/Pages/Shared/_Nav.cshtml create mode 100644 src/IdentitySvc/Pages/Shared/_ValidationSummary.cshtml create mode 100644 src/IdentitySvc/Pages/Telemetry.cs create mode 100644 src/IdentitySvc/Pages/_ViewImports.cshtml create mode 100644 src/IdentitySvc/Pages/_ViewStart.cshtml create mode 100644 src/IdentitySvc/Program.cs create mode 100644 src/IdentitySvc/Properties/launchSettings.json create mode 100644 src/IdentitySvc/SeedData.cs create mode 100644 src/IdentitySvc/buildschema.bat create mode 100644 src/IdentitySvc/buildschema.sh create mode 100644 src/IdentitySvc/wwwroot/css/site.css create mode 100644 src/IdentitySvc/wwwroot/css/site.min.css create mode 100644 src/IdentitySvc/wwwroot/css/site.scss create mode 100644 src/IdentitySvc/wwwroot/duende-logo.svg create mode 100644 src/IdentitySvc/wwwroot/favicon.ico create mode 100644 src/IdentitySvc/wwwroot/js/signin-redirect.js create mode 100644 src/IdentitySvc/wwwroot/js/signout-redirect.js diff --git a/OptifitWebServices.sln b/OptifitWebServices.sln index e0fc7d6..07fd5fc 100644 --- a/OptifitWebServices.sln +++ b/OptifitWebServices.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CatalogService", "src\Catal EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "src\Shared\Shared.csproj", "{BF49B348-4188-4AC7-9ED4-5837F4B3BCD2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentitySvc", "src\IdentitySvc\IdentitySvc.csproj", "{74C8ACD5-5DC4-4466-8846-B552FF131304}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,9 +28,14 @@ Global {BF49B348-4188-4AC7-9ED4-5837F4B3BCD2}.Debug|Any CPU.Build.0 = Debug|Any CPU {BF49B348-4188-4AC7-9ED4-5837F4B3BCD2}.Release|Any CPU.ActiveCfg = Release|Any CPU {BF49B348-4188-4AC7-9ED4-5837F4B3BCD2}.Release|Any CPU.Build.0 = Release|Any CPU + {74C8ACD5-5DC4-4466-8846-B552FF131304}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74C8ACD5-5DC4-4466-8846-B552FF131304}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74C8ACD5-5DC4-4466-8846-B552FF131304}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74C8ACD5-5DC4-4466-8846-B552FF131304}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {54BE8DE8-08BD-429F-BCCA-3363A879D922} = {2A7200CA-F40B-4715-8726-4ED30C785FA4} {BF49B348-4188-4AC7-9ED4-5837F4B3BCD2} = {2A7200CA-F40B-4715-8726-4ED30C785FA4} + {74C8ACD5-5DC4-4466-8846-B552FF131304} = {2A7200CA-F40B-4715-8726-4ED30C785FA4} EndGlobalSection EndGlobal diff --git a/src/IdentitySvc/Config.cs b/src/IdentitySvc/Config.cs new file mode 100644 index 0000000..9628915 --- /dev/null +++ b/src/IdentitySvc/Config.cs @@ -0,0 +1,52 @@ +using Duende.IdentityServer.Models; + +namespace IdentitySvc; + +public static class Config +{ + public static IEnumerable IdentityResources => + new IdentityResource[] + { + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + }; + + public static IEnumerable ApiScopes => + new ApiScope[] + { + new ApiScope("scope1"), + new ApiScope("scope2"), + }; + + public static IEnumerable Clients => + new Client[] + { + // m2m client credentials flow client + new Client + { + ClientId = "m2m.client", + ClientName = "Client Credentials Client", + + AllowedGrantTypes = GrantTypes.ClientCredentials, + ClientSecrets = { new Secret("511536EF-F270-4058-80CA-1C89C192F69A".Sha256()) }, + + AllowedScopes = { "scope1" } + }, + + // interactive client using code flow + pkce + new Client + { + ClientId = "interactive", + ClientSecrets = { new Secret("49C1A7E1-0C79-4A89-A3D6-A37998FB86B0".Sha256()) }, + + AllowedGrantTypes = GrantTypes.Code, + + RedirectUris = { "https://localhost:44300/signin-oidc" }, + FrontChannelLogoutUri = "https://localhost:44300/signout-oidc", + PostLogoutRedirectUris = { "https://localhost:44300/signout-callback-oidc" }, + + AllowOfflineAccess = true, + AllowedScopes = { "openid", "profile", "scope2" } + }, + }; +} diff --git a/src/IdentitySvc/Data/ApplicationDbContext.cs b/src/IdentitySvc/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..d23944e --- /dev/null +++ b/src/IdentitySvc/Data/ApplicationDbContext.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using IdentitySvc.Models; + +namespace IdentitySvc.Data; + +public class ApplicationDbContext : IdentityDbContext +{ + public ApplicationDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + // Customize the ASP.NET Identity model and override the defaults if needed. + // For example, you can rename the ASP.NET Identity table names and more. + // Add your customizations after calling base.OnModelCreating(builder); + } +} diff --git a/src/IdentitySvc/Data/Migrations/20240123193529_Users.Designer.cs b/src/IdentitySvc/Data/Migrations/20240123193529_Users.Designer.cs new file mode 100644 index 0000000..2188b5a --- /dev/null +++ b/src/IdentitySvc/Data/Migrations/20240123193529_Users.Designer.cs @@ -0,0 +1,268 @@ +// +using System; +using IdentitySvc.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IdentitySvc.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20240123193529_Users")] + partial class Users + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + + modelBuilder.Entity("IdentitySvc.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("IdentitySvc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("IdentitySvc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentitySvc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("IdentitySvc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IdentitySvc/Data/Migrations/20240123193529_Users.cs b/src/IdentitySvc/Data/Migrations/20240123193529_Users.cs new file mode 100644 index 0000000..2c117d9 --- /dev/null +++ b/src/IdentitySvc/Data/Migrations/20240123193529_Users.cs @@ -0,0 +1,222 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace IdentitySvc.Data.Migrations +{ + /// + public partial class Users : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + Email = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "TEXT", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "INTEGER", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: true), + SecurityStamp = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", nullable: true), + PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), + TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), + LockoutEnd = table.Column(type: "TEXT", nullable: true), + LockoutEnabled = table.Column(type: "INTEGER", nullable: false), + AccessFailedCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoleId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", 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(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", 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(type: "TEXT", nullable: false), + ProviderKey = table.Column(type: "TEXT", nullable: false), + ProviderDisplayName = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(type: "TEXT", 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(type: "TEXT", nullable: false), + RoleId = table.Column(type: "TEXT", 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(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", 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); + + 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); + } + + /// + 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/IdentitySvc/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/IdentitySvc/Data/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..295f312 --- /dev/null +++ b/src/IdentitySvc/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,265 @@ +// +using System; +using IdentitySvc.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace IdentitySvc.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + + modelBuilder.Entity("IdentitySvc.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("IdentitySvc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("IdentitySvc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("IdentitySvc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("IdentitySvc.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IdentitySvc/HostingExtensions.cs b/src/IdentitySvc/HostingExtensions.cs new file mode 100644 index 0000000..881e0e2 --- /dev/null +++ b/src/IdentitySvc/HostingExtensions.cs @@ -0,0 +1,73 @@ +using Duende.IdentityServer; +using IdentitySvc.Data; +using IdentitySvc.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace IdentitySvc; + +internal static class HostingExtensions +{ + public static WebApplication ConfigureServices(this WebApplicationBuilder builder) + { + builder.Services.AddRazorPages(); + + builder.Services.AddDbContext(options => + options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"))); + + builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + builder.Services + .AddIdentityServer(options => + { + options.Events.RaiseErrorEvents = true; + options.Events.RaiseInformationEvents = true; + options.Events.RaiseFailureEvents = true; + options.Events.RaiseSuccessEvents = true; + + // see https://docs.duendesoftware.com/identityserver/v6/fundamentals/resources/ + options.EmitStaticAudienceClaim = true; + }) + .AddInMemoryIdentityResources(Config.IdentityResources) + .AddInMemoryApiScopes(Config.ApiScopes) + .AddInMemoryClients(Config.Clients) + .AddAspNetIdentity(); + + builder.Services.AddAuthentication() + .AddGoogle(options => + { + options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme; + + // register your IdentityServer with Google at https://console.developers.google.com + // enable the Google+ API + // set the redirect URI to https://localhost:5001/signin-google + options.ClientId = "copy client ID from Google here"; + options.ClientSecret = "copy client secret from Google here"; + }); + + return builder.Build(); + } + + public static WebApplication ConfigurePipeline(this WebApplication app) + { + app.UseSerilogRequestLogging(); + + if (app.Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseStaticFiles(); + app.UseRouting(); + app.UseIdentityServer(); + app.UseAuthorization(); + + app.MapRazorPages() + .RequireAuthorization(); + + return app; + } +} \ No newline at end of file diff --git a/src/IdentitySvc/IdentitySvc.csproj b/src/IdentitySvc/IdentitySvc.csproj new file mode 100644 index 0000000..1b7d3dd --- /dev/null +++ b/src/IdentitySvc/IdentitySvc.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/IdentitySvc/Models/ApplicationUser.cs b/src/IdentitySvc/Models/ApplicationUser.cs new file mode 100644 index 0000000..b570cb4 --- /dev/null +++ b/src/IdentitySvc/Models/ApplicationUser.cs @@ -0,0 +1,12 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +using Microsoft.AspNetCore.Identity; + +namespace IdentitySvc.Models; + +// Add profile data for application users by adding properties to the ApplicationUser class +public class ApplicationUser : IdentityUser +{ +} diff --git a/src/IdentitySvc/Pages/Account/AccessDenied.cshtml b/src/IdentitySvc/Pages/Account/AccessDenied.cshtml new file mode 100644 index 0000000..65c56cf --- /dev/null +++ b/src/IdentitySvc/Pages/Account/AccessDenied.cshtml @@ -0,0 +1,10 @@ +@page +@model IdentitySvc.Pages.Account.AccessDeniedModel +@{ +} +
+
+

Access Denied

+

You do not have permission to access that resource.

+
+
\ No newline at end of file diff --git a/src/IdentitySvc/Pages/Account/AccessDenied.cshtml.cs b/src/IdentitySvc/Pages/Account/AccessDenied.cshtml.cs new file mode 100644 index 0000000..d9a51d4 --- /dev/null +++ b/src/IdentitySvc/Pages/Account/AccessDenied.cshtml.cs @@ -0,0 +1,13 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Account; + +public class AccessDeniedModel : PageModel +{ + public void OnGet() + { + } +} diff --git a/src/IdentitySvc/Pages/Account/Login/Index.cshtml b/src/IdentitySvc/Pages/Account/Login/Index.cshtml new file mode 100644 index 0000000..c40a6e9 --- /dev/null +++ b/src/IdentitySvc/Pages/Account/Login/Index.cshtml @@ -0,0 +1,89 @@ +@page +@model IdentitySvc.Pages.Login.Index + + \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Account/Login/Index.cshtml.cs b/src/IdentitySvc/Pages/Account/Login/Index.cshtml.cs new file mode 100644 index 0000000..2a37d18 --- /dev/null +++ b/src/IdentitySvc/Pages/Account/Login/Index.cshtml.cs @@ -0,0 +1,216 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer; +using Duende.IdentityServer.Events; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using IdentitySvc.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Login; + +[SecurityHeaders] +[AllowAnonymous] +public class Index : PageModel +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IIdentityServerInteractionService _interaction; + private readonly IEventService _events; + private readonly IAuthenticationSchemeProvider _schemeProvider; + private readonly IIdentityProviderStore _identityProviderStore; + + public ViewModel View { get; set; } = default!; + + [BindProperty] + public InputModel Input { get; set; } = default!; + + public Index( + IIdentityServerInteractionService interaction, + IAuthenticationSchemeProvider schemeProvider, + IIdentityProviderStore identityProviderStore, + IEventService events, + UserManager userManager, + SignInManager signInManager) + { + _userManager = userManager; + _signInManager = signInManager; + _interaction = interaction; + _schemeProvider = schemeProvider; + _identityProviderStore = identityProviderStore; + _events = events; + } + + public async Task OnGet(string? returnUrl) + { + await BuildModelAsync(returnUrl); + + if (View.IsExternalLoginOnly) + { + // we only have one option for logging in and it's an external provider + return RedirectToPage("/ExternalLogin/Challenge", new { scheme = View.ExternalLoginScheme, returnUrl }); + } + + return Page(); + } + + public async Task OnPost() + { + // check if we are in the context of an authorization request + var context = await _interaction.GetAuthorizationContextAsync(Input.ReturnUrl); + + // the user clicked the "cancel" button + if (Input.Button != "login") + { + if (context != null) + { + // This "can't happen", because if the ReturnUrl was null, then the context would be null + ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl)); + + // if the user cancels, send a result back into IdentityServer as if they + // denied the consent (even if this client does not require consent). + // this will send back an access denied OIDC error response to the client. + await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied); + + // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null + if (context.IsNativeClient()) + { + // The client is native, so this change in how to + // return the response is for better UX for the end user. + return this.LoadingPage(Input.ReturnUrl); + } + + return Redirect(Input.ReturnUrl ?? "~/"); + } + else + { + // since we don't have a valid context, then we just go back to the home page + return Redirect("~/"); + } + } + + if (ModelState.IsValid) + { + var result = await _signInManager.PasswordSignInAsync(Input.Username!, Input.Password!, Input.RememberLogin, lockoutOnFailure: true); + if (result.Succeeded) + { + var user = await _userManager.FindByNameAsync(Input.Username!); + await _events.RaiseAsync(new UserLoginSuccessEvent(user!.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider); + + if (context != null) + { + // This "can't happen", because if the ReturnUrl was null, then the context would be null + ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl)); + + if (context.IsNativeClient()) + { + // The client is native, so this change in how to + // return the response is for better UX for the end user. + return this.LoadingPage(Input.ReturnUrl); + } + + // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null + return Redirect(Input.ReturnUrl ?? "~/"); + } + + // request for a local page + if (Url.IsLocalUrl(Input.ReturnUrl)) + { + return Redirect(Input.ReturnUrl); + } + else if (string.IsNullOrEmpty(Input.ReturnUrl)) + { + return Redirect("~/"); + } + else + { + // user might have clicked on a malicious link - should be logged + throw new ArgumentException("invalid return URL"); + } + } + + const string error = "invalid credentials"; + await _events.RaiseAsync(new UserLoginFailureEvent(Input.Username, error, clientId:context?.Client.ClientId)); + Telemetry.Metrics.UserLoginFailure(context?.Client.ClientId, IdentityServerConstants.LocalIdentityProvider, error); + ModelState.AddModelError(string.Empty, LoginOptions.InvalidCredentialsErrorMessage); + } + + // something went wrong, show form with error + await BuildModelAsync(Input.ReturnUrl); + return Page(); + } + + private async Task BuildModelAsync(string? returnUrl) + { + Input = new InputModel + { + ReturnUrl = returnUrl + }; + + var context = await _interaction.GetAuthorizationContextAsync(returnUrl); + if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null) + { + var local = context.IdP == Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider; + + // this is meant to short circuit the UI and only trigger the one external IdP + View = new ViewModel + { + EnableLocalLogin = local, + }; + + Input.Username = context.LoginHint; + + if (!local) + { + View.ExternalProviders = new[] { new ViewModel.ExternalProvider ( authenticationScheme: context.IdP ) }; + } + + return; + } + + var schemes = await _schemeProvider.GetAllSchemesAsync(); + + var providers = schemes + .Where(x => x.DisplayName != null) + .Select(x => new ViewModel.ExternalProvider + ( + authenticationScheme: x.Name, + displayName: x.DisplayName ?? x.Name + )).ToList(); + + var dynamicSchemes = (await _identityProviderStore.GetAllSchemeNamesAsync()) + .Where(x => x.Enabled) + .Select(x => new ViewModel.ExternalProvider + ( + authenticationScheme: x.Scheme, + displayName: x.DisplayName ?? x.Scheme + )); + providers.AddRange(dynamicSchemes); + + + var allowLocal = true; + var client = context?.Client; + if (client != null) + { + allowLocal = client.EnableLocalLogin; + if (client.IdentityProviderRestrictions != null && client.IdentityProviderRestrictions.Count != 0) + { + providers = providers.Where(provider => client.IdentityProviderRestrictions.Contains(provider.AuthenticationScheme)).ToList(); + } + } + + View = new ViewModel + { + AllowRememberLogin = LoginOptions.AllowRememberLogin, + EnableLocalLogin = allowLocal && LoginOptions.AllowLocalLogin, + ExternalProviders = providers.ToArray() + }; + } +} diff --git a/src/IdentitySvc/Pages/Account/Login/InputModel.cs b/src/IdentitySvc/Pages/Account/Login/InputModel.cs new file mode 100644 index 0000000..7b40060 --- /dev/null +++ b/src/IdentitySvc/Pages/Account/Login/InputModel.cs @@ -0,0 +1,17 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.ComponentModel.DataAnnotations; + +namespace IdentitySvc.Pages.Login; + +public class InputModel +{ + [Required] + public string? Username { get; set; } + [Required] + public string? Password { get; set; } + public bool RememberLogin { get; set; } + public string? ReturnUrl { get; set; } + public string? Button { get; set; } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Account/Login/LoginOptions.cs b/src/IdentitySvc/Pages/Account/Login/LoginOptions.cs new file mode 100644 index 0000000..087784e --- /dev/null +++ b/src/IdentitySvc/Pages/Account/Login/LoginOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Login; + +public static class LoginOptions +{ + public static readonly bool AllowLocalLogin = true; + public static readonly bool AllowRememberLogin = true; + public static readonly TimeSpan RememberMeLoginDuration = TimeSpan.FromDays(30); + public static readonly string InvalidCredentialsErrorMessage = "Invalid username or password"; +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Account/Login/ViewModel.cs b/src/IdentitySvc/Pages/Account/Login/ViewModel.cs new file mode 100644 index 0000000..ebdf99f --- /dev/null +++ b/src/IdentitySvc/Pages/Account/Login/ViewModel.cs @@ -0,0 +1,28 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Login; + +public class ViewModel +{ + public bool AllowRememberLogin { get; set; } = true; + public bool EnableLocalLogin { get; set; } = true; + + public IEnumerable ExternalProviders { get; set; } = Enumerable.Empty(); + public IEnumerable VisibleExternalProviders => ExternalProviders.Where(x => !String.IsNullOrWhiteSpace(x.DisplayName)); + + public bool IsExternalLoginOnly => EnableLocalLogin == false && ExternalProviders?.Count() == 1; + public string? ExternalLoginScheme => IsExternalLoginOnly ? ExternalProviders?.SingleOrDefault()?.AuthenticationScheme : null; + + public class ExternalProvider + { + public ExternalProvider(string authenticationScheme, string? displayName = null) + { + AuthenticationScheme = authenticationScheme; + DisplayName = displayName; + } + + public string? DisplayName { get; set; } + public string AuthenticationScheme { get; set; } + } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Account/Logout/Index.cshtml b/src/IdentitySvc/Pages/Account/Logout/Index.cshtml new file mode 100644 index 0000000..79cb1f5 --- /dev/null +++ b/src/IdentitySvc/Pages/Account/Logout/Index.cshtml @@ -0,0 +1,17 @@ +@page +@model IdentitySvc.Pages.Logout.Index + +
+
+

Logout

+

Would you like to logout of IdentityServer?

+
+ +
+ + +
+ +
+
+
\ No newline at end of file diff --git a/src/IdentitySvc/Pages/Account/Logout/Index.cshtml.cs b/src/IdentitySvc/Pages/Account/Logout/Index.cshtml.cs new file mode 100644 index 0000000..c73c93e --- /dev/null +++ b/src/IdentitySvc/Pages/Account/Logout/Index.cshtml.cs @@ -0,0 +1,104 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Events; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Services; +using IdentityModel; +using IdentitySvc.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Logout; + +[SecurityHeaders] +[AllowAnonymous] +public class Index : PageModel +{ + private readonly SignInManager _signInManager; + private readonly IIdentityServerInteractionService _interaction; + private readonly IEventService _events; + + [BindProperty] + public string? LogoutId { get; set; } + + public Index(SignInManager signInManager, IIdentityServerInteractionService interaction, IEventService events) + { + _signInManager = signInManager; + _interaction = interaction; + _events = events; + } + + public async Task OnGet(string? logoutId) + { + LogoutId = logoutId; + + var showLogoutPrompt = LogoutOptions.ShowLogoutPrompt; + + if (User.Identity?.IsAuthenticated != true) + { + // if the user is not authenticated, then just show logged out page + showLogoutPrompt = false; + } + else + { + var context = await _interaction.GetLogoutContextAsync(LogoutId); + if (context?.ShowSignoutPrompt == false) + { + // it's safe to automatically sign-out + showLogoutPrompt = false; + } + } + + if (showLogoutPrompt == false) + { + // if the request for logout was properly authenticated from IdentityServer, then + // we don't need to show the prompt and can just log the user out directly. + return await OnPost(); + } + + return Page(); + } + + public async Task OnPost() + { + if (User.Identity?.IsAuthenticated == true) + { + // if there's no current logout context, we need to create one + // this captures necessary info from the current logged in user + // this can still return null if there is no context needed + LogoutId ??= await _interaction.CreateLogoutContextAsync(); + + // delete local authentication cookie + await _signInManager.SignOutAsync(); + + // see if we need to trigger federated logout + var idp = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; + + // raise the logout event + await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName())); + Telemetry.Metrics.UserLogout(idp); + + // if it's a local login we can ignore this workflow + if (idp != null && idp != Duende.IdentityServer.IdentityServerConstants.LocalIdentityProvider) + { + // we need to see if the provider supports external logout + if (await HttpContext.GetSchemeSupportsSignOutAsync(idp)) + { + // build a return URL so the upstream provider will redirect back + // to us after the user has logged out. this allows us to then + // complete our single sign-out processing. + var url = Url.Page("/Account/Logout/Loggedout", new { logoutId = LogoutId }); + + // this triggers a redirect to the external provider for sign-out + return SignOut(new AuthenticationProperties { RedirectUri = url }, idp); + } + } + } + + return RedirectToPage("/Account/Logout/LoggedOut", new { logoutId = LogoutId }); + } +} diff --git a/src/IdentitySvc/Pages/Account/Logout/LoggedOut.cshtml b/src/IdentitySvc/Pages/Account/Logout/LoggedOut.cshtml new file mode 100644 index 0000000..d57a2a8 --- /dev/null +++ b/src/IdentitySvc/Pages/Account/Logout/LoggedOut.cshtml @@ -0,0 +1,30 @@ +@page +@model IdentitySvc.Pages.Logout.LoggedOut + +
+

+ Logout + You are now logged out +

+ + @if (Model.View.PostLogoutRedirectUri != null) + { +
+ Click here to return to the + @Model.View.ClientName application. +
+ } + + @if (Model.View.SignOutIframeUrl != null) + { + + } +
+ +@section scripts +{ + @if (Model.View.AutomaticRedirectAfterSignOut) + { + + } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Account/Logout/LoggedOut.cshtml.cs b/src/IdentitySvc/Pages/Account/Logout/LoggedOut.cshtml.cs new file mode 100644 index 0000000..57ffcaa --- /dev/null +++ b/src/IdentitySvc/Pages/Account/Logout/LoggedOut.cshtml.cs @@ -0,0 +1,36 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Logout; + +[SecurityHeaders] +[AllowAnonymous] +public class LoggedOut : PageModel +{ + private readonly IIdentityServerInteractionService _interactionService; + + public LoggedOutViewModel View { get; set; } = default!; + + public LoggedOut(IIdentityServerInteractionService interactionService) + { + _interactionService = interactionService; + } + + public async Task OnGet(string? logoutId) + { + // get context information (client name, post logout redirect URI and iframe for federated signout) + var logout = await _interactionService.GetLogoutContextAsync(logoutId); + + View = new LoggedOutViewModel + { + AutomaticRedirectAfterSignOut = LogoutOptions.AutomaticRedirectAfterSignOut, + PostLogoutRedirectUri = logout?.PostLogoutRedirectUri, + ClientName = String.IsNullOrEmpty(logout?.ClientName) ? logout?.ClientId : logout?.ClientName, + SignOutIframeUrl = logout?.SignOutIFrameUrl + }; + } +} diff --git a/src/IdentitySvc/Pages/Account/Logout/LoggedOutViewModel.cs b/src/IdentitySvc/Pages/Account/Logout/LoggedOutViewModel.cs new file mode 100644 index 0000000..c066a48 --- /dev/null +++ b/src/IdentitySvc/Pages/Account/Logout/LoggedOutViewModel.cs @@ -0,0 +1,15 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Logout; + +public class LoggedOutViewModel +{ + public string? PostLogoutRedirectUri { get; set; } + public string? ClientName { get; set; } + public string? SignOutIframeUrl { get; set; } + public bool AutomaticRedirectAfterSignOut { get; set; } +} diff --git a/src/IdentitySvc/Pages/Account/Logout/LogoutOptions.cs b/src/IdentitySvc/Pages/Account/Logout/LogoutOptions.cs new file mode 100644 index 0000000..7cbdd3f --- /dev/null +++ b/src/IdentitySvc/Pages/Account/Logout/LogoutOptions.cs @@ -0,0 +1,11 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +namespace IdentitySvc.Pages.Logout; + +public static class LogoutOptions +{ + public static readonly bool ShowLogoutPrompt = true; + public static readonly bool AutomaticRedirectAfterSignOut = false; +} diff --git a/src/IdentitySvc/Pages/Ciba/All.cshtml b/src/IdentitySvc/Pages/Ciba/All.cshtml new file mode 100644 index 0000000..775cd57 --- /dev/null +++ b/src/IdentitySvc/Pages/Ciba/All.cshtml @@ -0,0 +1,48 @@ +@page +@model IdentitySvc.Pages.Ciba.AllModel +@{ +} + +
+
+
+
+
+

Pending Backchannel Login Requests

+
+
+ @if (Model.Logins.Any()) + { + + + + + + + + + + + @foreach (var login in Model.Logins) + { + + + + + + + } + +
IdClient IdBinding Message
@login.InternalId@login.Client.ClientId@login.BindingMessage + Process +
+ } + else + { +
No Pending Login Requests
+ } +
+
+
+
+
\ No newline at end of file diff --git a/src/IdentitySvc/Pages/Ciba/All.cshtml.cs b/src/IdentitySvc/Pages/Ciba/All.cshtml.cs new file mode 100644 index 0000000..4fd3efb --- /dev/null +++ b/src/IdentitySvc/Pages/Ciba/All.cshtml.cs @@ -0,0 +1,28 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Ciba; + +[SecurityHeaders] +[Authorize] +public class AllModel : PageModel +{ + public IEnumerable Logins { get; set; } = default!; + + private readonly IBackchannelAuthenticationInteractionService _backchannelAuthenticationInteraction; + + public AllModel(IBackchannelAuthenticationInteractionService backchannelAuthenticationInteractionService) + { + _backchannelAuthenticationInteraction = backchannelAuthenticationInteractionService; + } + + public async Task OnGet() + { + Logins = await _backchannelAuthenticationInteraction.GetPendingLoginRequestsForCurrentUserAsync(); + } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Ciba/Consent.cshtml b/src/IdentitySvc/Pages/Ciba/Consent.cshtml new file mode 100644 index 0000000..da3620c --- /dev/null +++ b/src/IdentitySvc/Pages/Ciba/Consent.cshtml @@ -0,0 +1,98 @@ +@page +@model IdentitySvc.Pages.Ciba.Consent +@{ +} + + diff --git a/src/IdentitySvc/Pages/Ciba/Consent.cshtml.cs b/src/IdentitySvc/Pages/Ciba/Consent.cshtml.cs new file mode 100644 index 0000000..e2e4fcc --- /dev/null +++ b/src/IdentitySvc/Pages/Ciba/Consent.cshtml.cs @@ -0,0 +1,228 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Events; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Validation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Ciba; + +[Authorize] +[SecurityHeaders] +public class Consent : PageModel +{ + private readonly IBackchannelAuthenticationInteractionService _interaction; + private readonly IEventService _events; + private readonly ILogger _logger; + + public Consent( + IBackchannelAuthenticationInteractionService interaction, + IEventService events, + ILogger logger) + { + _interaction = interaction; + _events = events; + _logger = logger; + } + + public ViewModel View { get; set; } = default!; + + [BindProperty] + public InputModel Input { get; set; } = default!; + + public async Task OnGet(string? id) + { + if (!await SetViewModelAsync(id)) + { + return RedirectToPage("/Home/Error/Index"); + } + + Input = new InputModel + { + Id = id + }; + + return Page(); + } + + public async Task OnPost() + { + // validate return url is still valid + var request = await _interaction.GetLoginRequestByInternalIdAsync(Input.Id ?? throw new ArgumentNullException(nameof(Input.Id))); + if (request == null || request.Subject.GetSubjectId() != User.GetSubjectId()) + { + _logger.InvalidId(Input.Id); + return RedirectToPage("/Home/Error/Index"); + } + + CompleteBackchannelLoginRequest? result = null; + + // user clicked 'no' - send back the standard 'access_denied' response + if (Input.Button == "no") + { + result = new CompleteBackchannelLoginRequest(Input.Id); + + // emit event + await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); + } + // user clicked 'yes' - validate the data + else if (Input.Button == "yes") + { + // if the user consented to some scope, build the response model + if (Input.ScopesConsented.Any()) + { + var scopes = Input.ScopesConsented; + if (ConsentOptions.EnableOfflineAccess == false) + { + scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess); + } + + result = new CompleteBackchannelLoginRequest(Input.Id) + { + ScopesValuesConsented = scopes.ToArray(), + Description = Input.Description + }; + + // emit event + await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, result.ScopesValuesConsented, false)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, result.ScopesValuesConsented, false); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(result.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); + } + else + { + ModelState.AddModelError("", ConsentOptions.MustChooseOneErrorMessage); + } + } + else + { + ModelState.AddModelError("", ConsentOptions.InvalidSelectionErrorMessage); + } + + if (result != null) + { + // communicate outcome of consent back to identityserver + await _interaction.CompleteLoginRequestAsync(result); + + return RedirectToPage("/Ciba/All"); + } + + // we need to redisplay the consent UI + if (!await SetViewModelAsync(Input.Id)) + { + return RedirectToPage("/Home/Error/Index"); + } + return Page(); + } + + private async Task SetViewModelAsync(string? id) + { + ArgumentNullException.ThrowIfNull(id); + + var request = await _interaction.GetLoginRequestByInternalIdAsync(id); + if (request != null && request.Subject.GetSubjectId() == User.GetSubjectId()) + { + View = CreateConsentViewModel(request); + return true; + } + else + { + _logger.NoMatchingBackchannelLoginRequest(id); + return false; + } + } + + private ViewModel CreateConsentViewModel(BackchannelUserLoginRequest request) + { + var vm = new ViewModel + { + ClientName = request.Client.ClientName ?? request.Client.ClientId, + ClientUrl = request.Client.ClientUri, + ClientLogoUrl = request.Client.LogoUri, + BindingMessage = request.BindingMessage + }; + + vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources + .Select(x => CreateScopeViewModel(x, Input == null || Input.ScopesConsented.Contains(x.Name))) + .ToArray(); + + var resourceIndicators = request.RequestedResourceIndicators ?? Enumerable.Empty(); + var apiResources = request.ValidatedResources.Resources.ApiResources.Where(x => resourceIndicators.Contains(x.Name)); + + var apiScopes = new List(); + foreach (var parsedScope in request.ValidatedResources.ParsedScopes) + { + var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName); + if (apiScope != null) + { + var scopeVm = CreateScopeViewModel(parsedScope, apiScope, Input == null || Input.ScopesConsented.Contains(parsedScope.RawValue)); + scopeVm.Resources = apiResources.Where(x => x.Scopes.Contains(parsedScope.ParsedName)) + .Select(x => new ResourceViewModel + { + Name = x.Name, + DisplayName = x.DisplayName ?? x.Name, + }).ToArray(); + apiScopes.Add(scopeVm); + } + } + if (ConsentOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess) + { + apiScopes.Add(GetOfflineAccessScope(Input == null || Input.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess))); + } + vm.ApiScopes = apiScopes; + + return vm; + } + + private static ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) + { + return new ScopeViewModel + { + Name = identity.Name, + Value = identity.Name, + DisplayName = identity.DisplayName ?? identity.Name, + Description = identity.Description, + Emphasize = identity.Emphasize, + Required = identity.Required, + Checked = check || identity.Required + }; + } + + private static ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) + { + var displayName = apiScope.DisplayName ?? apiScope.Name; + if (!String.IsNullOrWhiteSpace(parsedScopeValue.ParsedParameter)) + { + displayName += ":" + parsedScopeValue.ParsedParameter; + } + + return new ScopeViewModel + { + Name = parsedScopeValue.ParsedName, + Value = parsedScopeValue.RawValue, + DisplayName = displayName, + Description = apiScope.Description, + Emphasize = apiScope.Emphasize, + Required = apiScope.Required, + Checked = check || apiScope.Required + }; + } + + private static ScopeViewModel GetOfflineAccessScope(bool check) + { + return new ScopeViewModel + { + Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess, + DisplayName = ConsentOptions.OfflineAccessDisplayName, + Description = ConsentOptions.OfflineAccessDescription, + Emphasize = true, + Checked = check + }; + } +} diff --git a/src/IdentitySvc/Pages/Ciba/ConsentOptions.cs b/src/IdentitySvc/Pages/Ciba/ConsentOptions.cs new file mode 100644 index 0000000..f6886e7 --- /dev/null +++ b/src/IdentitySvc/Pages/Ciba/ConsentOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Ciba; + +public static class ConsentOptions +{ + public static readonly bool EnableOfflineAccess = true; + public static readonly string OfflineAccessDisplayName = "Offline Access"; + public static readonly string OfflineAccessDescription = "Access to your applications and resources, even when you are offline"; + + public static readonly string MustChooseOneErrorMessage = "You must pick at least one permission"; + public static readonly string InvalidSelectionErrorMessage = "Invalid selection"; +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Ciba/Index.cshtml b/src/IdentitySvc/Pages/Ciba/Index.cshtml new file mode 100644 index 0000000..485e5dc --- /dev/null +++ b/src/IdentitySvc/Pages/Ciba/Index.cshtml @@ -0,0 +1,30 @@ +@page +@model IdentitySvc.Pages.Ciba.IndexModel +@{ +} + +
+
+ @if (Model.LoginRequest.Client.LogoUri != null) + { + + } +

+ @Model.LoginRequest.Client.ClientName + is requesting your permission +

+ +

+ Verify that this identifier matches what the client is displaying: + @Model.LoginRequest.BindingMessage +

+ +

+ Do you wish to continue? +

+ + +
+
diff --git a/src/IdentitySvc/Pages/Ciba/Index.cshtml.cs b/src/IdentitySvc/Pages/Ciba/Index.cshtml.cs new file mode 100644 index 0000000..c9ffe43 --- /dev/null +++ b/src/IdentitySvc/Pages/Ciba/Index.cshtml.cs @@ -0,0 +1,42 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Ciba; + +[AllowAnonymous] +[SecurityHeaders] +public class IndexModel : PageModel +{ + public BackchannelUserLoginRequest LoginRequest { get; set; } = default!; + + private readonly IBackchannelAuthenticationInteractionService _backchannelAuthenticationInteraction; + private readonly ILogger _logger; + + public IndexModel(IBackchannelAuthenticationInteractionService backchannelAuthenticationInteractionService, ILogger logger) + { + _backchannelAuthenticationInteraction = backchannelAuthenticationInteractionService; + _logger = logger; + } + + public async Task OnGet(string id) + { + var result = await _backchannelAuthenticationInteraction.GetLoginRequestByInternalIdAsync(id); + if (result == null) + { + _logger.InvalidBackchannelLoginId(id); + return RedirectToPage("/Home/Error/Index"); + } + else + { + LoginRequest = result; + } + + return Page(); + } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Ciba/InputModel.cs b/src/IdentitySvc/Pages/Ciba/InputModel.cs new file mode 100644 index 0000000..6a25a10 --- /dev/null +++ b/src/IdentitySvc/Pages/Ciba/InputModel.cs @@ -0,0 +1,12 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Ciba; + +public class InputModel +{ + public string? Button { get; set; } + public IEnumerable ScopesConsented { get; set; } = new List(); + public string? Id { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Ciba/ViewModel.cs b/src/IdentitySvc/Pages/Ciba/ViewModel.cs new file mode 100644 index 0000000..c96faa3 --- /dev/null +++ b/src/IdentitySvc/Pages/Ciba/ViewModel.cs @@ -0,0 +1,34 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Ciba; + +public class ViewModel +{ + public string? ClientName { get; set; } + public string? ClientUrl { get; set; } + public string? ClientLogoUrl { get; set; } + + public string? BindingMessage { get; set; } + + public IEnumerable IdentityScopes { get; set; } = Enumerable.Empty(); + public IEnumerable ApiScopes { get; set; } = Enumerable.Empty(); +} + +public class ScopeViewModel +{ + public string? Name { get; set; } + public string? Value { get; set; } + public string? DisplayName { get; set; } + public string? Description { get; set; } + public bool Emphasize { get; set; } + public bool Required { get; set; } + public bool Checked { get; set; } + public IEnumerable Resources { get; set; } = Enumerable.Empty(); +} + +public class ResourceViewModel +{ + public string? Name { get; set; } + public string? DisplayName { get; set; } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Ciba/_ScopeListItem.cshtml b/src/IdentitySvc/Pages/Ciba/_ScopeListItem.cshtml new file mode 100644 index 0000000..4ed96b8 --- /dev/null +++ b/src/IdentitySvc/Pages/Ciba/_ScopeListItem.cshtml @@ -0,0 +1,47 @@ +@using IdentitySvc.Pages.Ciba +@model ScopeViewModel + +
  • + + @if (Model.Required) + { + (required) + } + @if (Model.Description != null) + { + + } + @if (Model.Resources?.Any() == true) + { + + } +
  • \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Consent/ConsentOptions.cs b/src/IdentitySvc/Pages/Consent/ConsentOptions.cs new file mode 100644 index 0000000..4916fbb --- /dev/null +++ b/src/IdentitySvc/Pages/Consent/ConsentOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Consent; + +public static class ConsentOptions +{ + public static readonly bool EnableOfflineAccess = true; + public static readonly string OfflineAccessDisplayName = "Offline Access"; + public static readonly string OfflineAccessDescription = "Access to your applications and resources, even when you are offline"; + + public static readonly string MustChooseOneErrorMessage = "You must pick at least one permission"; + public static readonly string InvalidSelectionErrorMessage = "Invalid selection"; +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Consent/Index.cshtml b/src/IdentitySvc/Pages/Consent/Index.cshtml new file mode 100644 index 0000000..eec803a --- /dev/null +++ b/src/IdentitySvc/Pages/Consent/Index.cshtml @@ -0,0 +1,107 @@ +@page +@model IdentitySvc.Pages.Consent.Index +@{ +} + + diff --git a/src/IdentitySvc/Pages/Consent/Index.cshtml.cs b/src/IdentitySvc/Pages/Consent/Index.cshtml.cs new file mode 100644 index 0000000..b649910 --- /dev/null +++ b/src/IdentitySvc/Pages/Consent/Index.cshtml.cs @@ -0,0 +1,236 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Events; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Validation; +using IdentityModel; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Consent; + +[Authorize] +[SecurityHeaders] +public class Index : PageModel +{ + private readonly IIdentityServerInteractionService _interaction; + private readonly IEventService _events; + private readonly ILogger _logger; + + public Index( + IIdentityServerInteractionService interaction, + IEventService events, + ILogger logger) + { + _interaction = interaction; + _events = events; + _logger = logger; + } + + public ViewModel View { get; set; } = default!; + + [BindProperty] + public InputModel Input { get; set; } = default!; + + public async Task OnGet(string? returnUrl) + { + if (!await SetViewModelAsync(returnUrl)) + { + return RedirectToPage("/Home/Error/Index"); + } + + Input = new InputModel + { + ReturnUrl = returnUrl, + }; + + return Page(); + } + + public async Task OnPost() + { + // validate return url is still valid + var request = await _interaction.GetAuthorizationContextAsync(Input.ReturnUrl); + if (request == null) return RedirectToPage("/Home/Error/Index"); + + ConsentResponse? grantedConsent = null; + + // user clicked 'no' - send back the standard 'access_denied' response + if (Input.Button == "no") + { + grantedConsent = new ConsentResponse { Error = AuthorizationError.AccessDenied }; + + // emit event + await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); + } + // user clicked 'yes' - validate the data + else if (Input.Button == "yes") + { + // if the user consented to some scope, build the response model + if (Input.ScopesConsented.Any()) + { + var scopes = Input.ScopesConsented; + if (ConsentOptions.EnableOfflineAccess == false) + { + scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess); + } + + grantedConsent = new ConsentResponse + { + RememberConsent = Input.RememberConsent, + ScopesValuesConsented = scopes.ToArray(), + Description = Input.Description + }; + + // emit event + await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); + } + else + { + ModelState.AddModelError("", ConsentOptions.MustChooseOneErrorMessage); + } + } + else + { + ModelState.AddModelError("", ConsentOptions.InvalidSelectionErrorMessage); + } + + if (grantedConsent != null) + { + ArgumentNullException.ThrowIfNull(Input.ReturnUrl, nameof(Input.ReturnUrl)); + + // communicate outcome of consent back to identityserver + await _interaction.GrantConsentAsync(request, grantedConsent); + + // redirect back to authorization endpoint + if (request.IsNativeClient() == true) + { + // The client is native, so this change in how to + // return the response is for better UX for the end user. + return this.LoadingPage(Input.ReturnUrl); + } + + return Redirect(Input.ReturnUrl); + } + + // we need to redisplay the consent UI + if (!await SetViewModelAsync(Input.ReturnUrl)) + { + return RedirectToPage("/Home/Error/Index"); + } + return Page(); + } + + private async Task SetViewModelAsync(string? returnUrl) + { + ArgumentNullException.ThrowIfNull(returnUrl); + + var request = await _interaction.GetAuthorizationContextAsync(returnUrl); + if (request != null) + { + View = CreateConsentViewModel(request); + return true; + } + else + { + _logger.NoConsentMatchingRequest(returnUrl); + return false; + } + } + + private ViewModel CreateConsentViewModel(AuthorizationRequest request) + { + var vm = new ViewModel + { + ClientName = request.Client.ClientName ?? request.Client.ClientId, + ClientUrl = request.Client.ClientUri, + ClientLogoUrl = request.Client.LogoUri, + AllowRememberConsent = request.Client.AllowRememberConsent + }; + + vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources + .Select(x => CreateScopeViewModel(x, Input == null || Input.ScopesConsented.Contains(x.Name))) + .ToArray(); + + var resourceIndicators = request.Parameters.GetValues(OidcConstants.AuthorizeRequest.Resource) ?? Enumerable.Empty(); + var apiResources = request.ValidatedResources.Resources.ApiResources.Where(x => resourceIndicators.Contains(x.Name)); + + var apiScopes = new List(); + foreach (var parsedScope in request.ValidatedResources.ParsedScopes) + { + var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName); + if (apiScope != null) + { + var scopeVm = CreateScopeViewModel(parsedScope, apiScope, Input == null || Input.ScopesConsented.Contains(parsedScope.RawValue)); + scopeVm.Resources = apiResources.Where(x => x.Scopes.Contains(parsedScope.ParsedName)) + .Select(x => new ResourceViewModel + { + Name = x.Name, + DisplayName = x.DisplayName ?? x.Name, + }).ToArray(); + apiScopes.Add(scopeVm); + } + } + if (ConsentOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess) + { + apiScopes.Add(CreateOfflineAccessScope(Input == null || Input.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess))); + } + vm.ApiScopes = apiScopes; + + return vm; + } + + private static ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) + { + return new ScopeViewModel + { + Name = identity.Name, + Value = identity.Name, + DisplayName = identity.DisplayName ?? identity.Name, + Description = identity.Description, + Emphasize = identity.Emphasize, + Required = identity.Required, + Checked = check || identity.Required + }; + } + + private static ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) + { + var displayName = apiScope.DisplayName ?? apiScope.Name; + if (!String.IsNullOrWhiteSpace(parsedScopeValue.ParsedParameter)) + { + displayName += ":" + parsedScopeValue.ParsedParameter; + } + + return new ScopeViewModel + { + Name = parsedScopeValue.ParsedName, + Value = parsedScopeValue.RawValue, + DisplayName = displayName, + Description = apiScope.Description, + Emphasize = apiScope.Emphasize, + Required = apiScope.Required, + Checked = check || apiScope.Required + }; + } + + private static ScopeViewModel CreateOfflineAccessScope(bool check) + { + return new ScopeViewModel + { + Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess, + DisplayName = ConsentOptions.OfflineAccessDisplayName, + Description = ConsentOptions.OfflineAccessDescription, + Emphasize = true, + Checked = check + }; + } +} diff --git a/src/IdentitySvc/Pages/Consent/InputModel.cs b/src/IdentitySvc/Pages/Consent/InputModel.cs new file mode 100644 index 0000000..d9a49d6 --- /dev/null +++ b/src/IdentitySvc/Pages/Consent/InputModel.cs @@ -0,0 +1,13 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Consent; + +public class InputModel +{ + public string? Button { get; set; } + public IEnumerable ScopesConsented { get; set; } = new List(); + public bool RememberConsent { get; set; } = true; + public string? ReturnUrl { get; set; } + public string? Description { get; set; } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Consent/ViewModel.cs b/src/IdentitySvc/Pages/Consent/ViewModel.cs new file mode 100644 index 0000000..88b3491 --- /dev/null +++ b/src/IdentitySvc/Pages/Consent/ViewModel.cs @@ -0,0 +1,33 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Consent; + +public class ViewModel +{ + public string? ClientName { get; set; } + public string? ClientUrl { get; set; } + public string? ClientLogoUrl { get; set; } + public bool AllowRememberConsent { get; set; } + + public IEnumerable IdentityScopes { get; set; } = Enumerable.Empty(); + public IEnumerable ApiScopes { get; set; } = Enumerable.Empty(); +} + +public class ScopeViewModel +{ + public string? Name { get; set; } + public string? Value { get; set; } + public string? DisplayName { get; set; } + public string? Description { get; set; } + public bool Emphasize { get; set; } + public bool Required { get; set; } + public bool Checked { get; set; } + public IEnumerable Resources { get; set; } = Enumerable.Empty(); +} + +public class ResourceViewModel +{ + public string? Name { get; set; } + public string? DisplayName { get; set; } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Consent/_ScopeListItem.cshtml b/src/IdentitySvc/Pages/Consent/_ScopeListItem.cshtml new file mode 100644 index 0000000..8fc4547 --- /dev/null +++ b/src/IdentitySvc/Pages/Consent/_ScopeListItem.cshtml @@ -0,0 +1,47 @@ +@using IdentitySvc.Pages.Consent +@model ScopeViewModel + +
  • + + @if (Model.Required) + { + (required) + } + @if (Model.Description != null) + { + + } + @if (Model.Resources?.Any() == true) + { + + } +
  • \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Device/DeviceOptions.cs b/src/IdentitySvc/Pages/Device/DeviceOptions.cs new file mode 100644 index 0000000..2a70255 --- /dev/null +++ b/src/IdentitySvc/Pages/Device/DeviceOptions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Device; + +public static class DeviceOptions +{ + public static readonly bool EnableOfflineAccess = true; + public static readonly string OfflineAccessDisplayName = "Offline Access"; + public static readonly string OfflineAccessDescription = "Access to your applications and resources, even when you are offline"; + + public static readonly string InvalidUserCode = "Invalid user code"; + public static readonly string MustChooseOneErrorMessage = "You must pick at least one permission"; + public static readonly string InvalidSelectionErrorMessage = "Invalid selection"; +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Device/Index.cshtml b/src/IdentitySvc/Pages/Device/Index.cshtml new file mode 100644 index 0000000..9c2adbd --- /dev/null +++ b/src/IdentitySvc/Pages/Device/Index.cshtml @@ -0,0 +1,141 @@ +@page +@model IdentitySvc.Pages.Device.Index +@{ +} + +@if (Model.Input.UserCode == null) +{ + @*We need to collect the user code*@ +
    +
    +

    User Code

    +

    Please enter the code displayed on your device.

    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    + + +
    + + +
    +
    +
    +
    +} +else +{ + @*collect consent for the user code provided*@ +
    +
    + @if (Model.View.ClientLogoUrl != null) + { + + } +

    + @Model.View.ClientName + is requesting your permission +

    +

    Please confirm that the authorization request matches the code: @Model.Input.UserCode.

    +

    Uncheck the permissions you do not wish to grant.

    +
    + +
    +
    + +
    +
    + +
    + +
    +
    + @if (Model.View.IdentityScopes.Any()) + { +
    +
    +
    + + Personal Information +
    +
      + @foreach (var scope in Model.View.IdentityScopes) + { + + } +
    +
    +
    + } + + @if (Model.View.ApiScopes.Any()) + { +
    +
    +
    + + Application Access +
    +
      + @foreach (var scope in Model.View.ApiScopes) + { + + } +
    +
    +
    + } + +
    +
    +
    + + Description +
    +
    + +
    +
    +
    + + @if (Model.View.AllowRememberConsent) + { +
    +
    + + +
    +
    + } +
    +
    + +
    +
    + + +
    +
    + @if (Model.View.ClientUrl != null) + { + + + @Model.View.ClientName + + } +
    +
    +
    +
    +} diff --git a/src/IdentitySvc/Pages/Device/Index.cshtml.cs b/src/IdentitySvc/Pages/Device/Index.cshtml.cs new file mode 100644 index 0000000..81ca290 --- /dev/null +++ b/src/IdentitySvc/Pages/Device/Index.cshtml.cs @@ -0,0 +1,220 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Events; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Validation; +using IdentitySvc.Pages.Consent; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; + +namespace IdentitySvc.Pages.Device; + +[SecurityHeaders] +[Authorize] +public class Index : PageModel +{ + private readonly IDeviceFlowInteractionService _interaction; + private readonly IEventService _events; + private readonly IOptions _options; + private readonly ILogger _logger; + + public Index( + IDeviceFlowInteractionService interaction, + IEventService eventService, + IOptions options, + ILogger logger) + { + _interaction = interaction; + _events = eventService; + _options = options; + _logger = logger; + } + + public ViewModel View { get; set; } = default!; + + [BindProperty] + public InputModel Input { get; set; } = default!; + + public async Task OnGet(string? userCode) + { + if (String.IsNullOrWhiteSpace(userCode)) + { + return Page(); + } + + if (!await SetViewModelAsync(userCode)) + { + ModelState.AddModelError("", DeviceOptions.InvalidUserCode); + return Page(); + } + + Input = new InputModel { + UserCode = userCode, + }; + + return Page(); + } + + public async Task OnPost() + { + var request = await _interaction.GetAuthorizationContextAsync(Input.UserCode ?? throw new ArgumentNullException(nameof(Input.UserCode))); + if (request == null) return RedirectToPage("/Home/Error/Index"); + + ConsentResponse? grantedConsent = null; + + // user clicked 'no' - send back the standard 'access_denied' response + if (Input.Button == "no") + { + grantedConsent = new ConsentResponse + { + Error = AuthorizationError.AccessDenied + }; + + // emit event + await _events.RaiseAsync(new ConsentDeniedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues)); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName)); + } + // user clicked 'yes' - validate the data + else if (Input.Button == "yes") + { + // if the user consented to some scope, build the response model + if (Input.ScopesConsented.Any()) + { + var scopes = Input.ScopesConsented; + if (ConsentOptions.EnableOfflineAccess == false) + { + scopes = scopes.Where(x => x != Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess); + } + + grantedConsent = new ConsentResponse + { + RememberConsent = Input.RememberConsent, + ScopesValuesConsented = scopes.ToArray(), + Description = Input.Description + }; + + // emit event + await _events.RaiseAsync(new ConsentGrantedEvent(User.GetSubjectId(), request.Client.ClientId, request.ValidatedResources.RawScopeValues, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent)); + Telemetry.Metrics.ConsentGranted(request.Client.ClientId, grantedConsent.ScopesValuesConsented, grantedConsent.RememberConsent); + var denied = request.ValidatedResources.ParsedScopes.Select(s => s.ParsedName).Except(grantedConsent.ScopesValuesConsented); + Telemetry.Metrics.ConsentDenied(request.Client.ClientId, denied); + } + else + { + ModelState.AddModelError("", ConsentOptions.MustChooseOneErrorMessage); + } + } + else + { + ModelState.AddModelError("", ConsentOptions.InvalidSelectionErrorMessage); + } + + if (grantedConsent != null) + { + // communicate outcome of consent back to identityserver + await _interaction.HandleRequestAsync(Input.UserCode, grantedConsent); + + // indicate that's it ok to redirect back to authorization endpoint + return RedirectToPage("/Device/Success"); + } + + // we need to redisplay the consent UI + if (!await SetViewModelAsync(Input.UserCode)) + { + return RedirectToPage("/Home/Error/Index"); + } + return Page(); + } + + + private async Task SetViewModelAsync(string userCode) + { + var request = await _interaction.GetAuthorizationContextAsync(userCode); + if (request != null) + { + View = CreateConsentViewModel(request); + return true; + } + else + { + View = new ViewModel(); + return false; + } + } + + private ViewModel CreateConsentViewModel(DeviceFlowAuthorizationRequest request) + { + var vm = new ViewModel + { + ClientName = request.Client.ClientName ?? request.Client.ClientId, + ClientUrl = request.Client.ClientUri, + ClientLogoUrl = request.Client.LogoUri, + AllowRememberConsent = request.Client.AllowRememberConsent + }; + + vm.IdentityScopes = request.ValidatedResources.Resources.IdentityResources.Select(x => CreateScopeViewModel(x, Input == null || Input.ScopesConsented.Contains(x.Name))).ToArray(); + + var apiScopes = new List(); + foreach (var parsedScope in request.ValidatedResources.ParsedScopes) + { + var apiScope = request.ValidatedResources.Resources.FindApiScope(parsedScope.ParsedName); + if (apiScope != null) + { + var scopeVm = CreateScopeViewModel(parsedScope, apiScope, Input == null || Input.ScopesConsented.Contains(parsedScope.RawValue)); + apiScopes.Add(scopeVm); + } + } + if (DeviceOptions.EnableOfflineAccess && request.ValidatedResources.Resources.OfflineAccess) + { + apiScopes.Add(GetOfflineAccessScope(Input == null || Input.ScopesConsented.Contains(Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess))); + } + vm.ApiScopes = apiScopes; + + return vm; + } + + private static ScopeViewModel CreateScopeViewModel(IdentityResource identity, bool check) + { + return new ScopeViewModel + { + Value = identity.Name, + DisplayName = identity.DisplayName ?? identity.Name, + Description = identity.Description, + Emphasize = identity.Emphasize, + Required = identity.Required, + Checked = check || identity.Required + }; + } + + private static ScopeViewModel CreateScopeViewModel(ParsedScopeValue parsedScopeValue, ApiScope apiScope, bool check) + { + return new ScopeViewModel + { + Value = parsedScopeValue.RawValue, + // todo: use the parsed scope value in the display? + DisplayName = apiScope.DisplayName ?? apiScope.Name, + Description = apiScope.Description, + Emphasize = apiScope.Emphasize, + Required = apiScope.Required, + Checked = check || apiScope.Required + }; + } + + private static ScopeViewModel GetOfflineAccessScope(bool check) + { + return new ScopeViewModel + { + Value = Duende.IdentityServer.IdentityServerConstants.StandardScopes.OfflineAccess, + DisplayName = DeviceOptions.OfflineAccessDisplayName, + Description = DeviceOptions.OfflineAccessDescription, + Emphasize = true, + Checked = check + }; + } +} diff --git a/src/IdentitySvc/Pages/Device/InputModel.cs b/src/IdentitySvc/Pages/Device/InputModel.cs new file mode 100644 index 0000000..4923ef9 --- /dev/null +++ b/src/IdentitySvc/Pages/Device/InputModel.cs @@ -0,0 +1,14 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Device; + +public class InputModel +{ + public string? Button { get; set; } + public IEnumerable ScopesConsented { get; set; } = new List(); + public bool RememberConsent { get; set; } = true; + public string? ReturnUrl { get; set; } + public string? Description { get; set; } + public string? UserCode { get; set; } +} diff --git a/src/IdentitySvc/Pages/Device/Success.cshtml b/src/IdentitySvc/Pages/Device/Success.cshtml new file mode 100644 index 0000000..d861d3d --- /dev/null +++ b/src/IdentitySvc/Pages/Device/Success.cshtml @@ -0,0 +1,12 @@ +@page +@model IdentitySvc.Pages.Device.SuccessModel +@{ +} + + +
    +
    +

    Success

    +

    You have successfully authorized the device

    +
    +
    diff --git a/src/IdentitySvc/Pages/Device/Success.cshtml.cs b/src/IdentitySvc/Pages/Device/Success.cshtml.cs new file mode 100644 index 0000000..5cefd21 --- /dev/null +++ b/src/IdentitySvc/Pages/Device/Success.cshtml.cs @@ -0,0 +1,16 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Device; + +[SecurityHeaders] +[Authorize] +public class SuccessModel : PageModel +{ + public void OnGet() + { + } +} diff --git a/src/IdentitySvc/Pages/Device/ViewModel.cs b/src/IdentitySvc/Pages/Device/ViewModel.cs new file mode 100644 index 0000000..bd2e5f2 --- /dev/null +++ b/src/IdentitySvc/Pages/Device/ViewModel.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Device; + +public class ViewModel +{ + public string? ClientName { get; set; } + public string? ClientUrl { get; set; } + public string? ClientLogoUrl { get; set; } + public bool AllowRememberConsent { get; set; } + + public IEnumerable IdentityScopes { get; set; } = Enumerable.Empty(); + public IEnumerable ApiScopes { get; set; } = Enumerable.Empty(); +} + +public class ScopeViewModel +{ + public string? Value { get; set; } + public string? DisplayName { get; set; } + public string? Description { get; set; } + public bool Emphasize { get; set; } + public bool Required { get; set; } + public bool Checked { get; set; } +} diff --git a/src/IdentitySvc/Pages/Device/_ScopeListItem.cshtml b/src/IdentitySvc/Pages/Device/_ScopeListItem.cshtml new file mode 100644 index 0000000..a930990 --- /dev/null +++ b/src/IdentitySvc/Pages/Device/_ScopeListItem.cshtml @@ -0,0 +1,35 @@ +@using IdentitySvc.Pages.Device +@model ScopeViewModel + +
  • + + @if (Model.Required) + { + (required) + } + @if (Model.Description != null) + { + + } +
  • \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Diagnostics/Index.cshtml b/src/IdentitySvc/Pages/Diagnostics/Index.cshtml new file mode 100644 index 0000000..76a5c8f --- /dev/null +++ b/src/IdentitySvc/Pages/Diagnostics/Index.cshtml @@ -0,0 +1,67 @@ +@page +@model IdentitySvc.Pages.Diagnostics.Index + +
    +
    +

    Authentication Cookie

    +
    + +
    +
    +
    +
    +

    Claims

    +
    +
    + @if(Model.View.AuthenticateResult.Principal != null) + { +
    + @foreach (var claim in Model.View.AuthenticateResult.Principal.Claims) + { +
    @claim.Type
    +
    @claim.Value
    + } +
    + } +
    +
    +
    + +
    +
    +
    +

    Properties

    +
    +
    +
    + @if (Model.View.AuthenticateResult.Properties != null) + { + @foreach (var prop in Model.View.AuthenticateResult.Properties.Items) + { +
    @prop.Key
    +
    @prop.Value
    + } + } + @if (Model.View.Clients.Any()) + { +
    Clients
    +
    + @{ + var clients = Model.View.Clients.ToArray(); + for(var i = 0; i < clients.Length; i++) + { + @clients[i] + if (i < clients.Length - 1) + { + , + } + } + } +
    + } +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Diagnostics/Index.cshtml.cs b/src/IdentitySvc/Pages/Diagnostics/Index.cshtml.cs new file mode 100644 index 0000000..a693057 --- /dev/null +++ b/src/IdentitySvc/Pages/Diagnostics/Index.cshtml.cs @@ -0,0 +1,34 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Authorization; + +namespace IdentitySvc.Pages.Diagnostics; + +[SecurityHeaders] +[Authorize] +public class Index : PageModel +{ + public ViewModel View { get; set; } = default!; + + public async Task OnGet() + { + var localAddresses = new List { "127.0.0.1", "::1" }; + if(HttpContext.Connection.LocalIpAddress != null) + { + localAddresses.Add(HttpContext.Connection.LocalIpAddress.ToString()); + } + + if (!localAddresses.Contains(HttpContext.Connection.RemoteIpAddress?.ToString())) + { + return NotFound(); + } + + View = new ViewModel(await HttpContext.AuthenticateAsync()); + + return Page(); + } +} diff --git a/src/IdentitySvc/Pages/Diagnostics/ViewModel.cs b/src/IdentitySvc/Pages/Diagnostics/ViewModel.cs new file mode 100644 index 0000000..7e77983 --- /dev/null +++ b/src/IdentitySvc/Pages/Diagnostics/ViewModel.cs @@ -0,0 +1,32 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using IdentityModel; +using Microsoft.AspNetCore.Authentication; +using System.Text; +using System.Text.Json; + +namespace IdentitySvc.Pages.Diagnostics; + +public class ViewModel +{ + public ViewModel(AuthenticateResult result) + { + AuthenticateResult = result; + + if (result?.Properties?.Items.TryGetValue("client_list", out var encoded) == true) + { + if (encoded != null) + { + var bytes = Base64Url.Decode(encoded); + var value = Encoding.UTF8.GetString(bytes); + Clients = JsonSerializer.Deserialize(value) ?? Enumerable.Empty(); + return; + } + } + Clients = Enumerable.Empty(); + } + + public AuthenticateResult AuthenticateResult { get; } + public IEnumerable Clients { get; } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Extensions.cs b/src/IdentitySvc/Pages/Extensions.cs new file mode 100644 index 0000000..fdf6959 --- /dev/null +++ b/src/IdentitySvc/Pages/Extensions.cs @@ -0,0 +1,42 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages; + +public static class Extensions +{ + /// + /// Determines if the authentication scheme support signout. + /// + internal static async Task GetSchemeSupportsSignOutAsync(this HttpContext context, string scheme) + { + var provider = context.RequestServices.GetRequiredService(); + var handler = await provider.GetHandlerAsync(context, scheme); + return (handler is IAuthenticationSignOutHandler); + } + + /// + /// Checks if the redirect URI is for a native client. + /// + internal static bool IsNativeClient(this AuthorizationRequest context) + { + return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal) + && !context.RedirectUri.StartsWith("http", StringComparison.Ordinal); + } + + /// + /// Renders a loading page that is used to redirect back to the redirectUri. + /// + internal static IActionResult LoadingPage(this PageModel page, string? redirectUri) + { + page.HttpContext.Response.StatusCode = 200; + page.HttpContext.Response.Headers["Location"] = ""; + + return page.RedirectToPage("/Redirect/Index", new { RedirectUri = redirectUri }); + } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml b/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml new file mode 100644 index 0000000..5603939 --- /dev/null +++ b/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml @@ -0,0 +1,19 @@ +@page +@model IdentitySvc.Pages.ExternalLogin.Callback + +@{ + Layout = null; +} + + + + + + + + +
    + +
    + + \ No newline at end of file diff --git a/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml.cs b/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml.cs new file mode 100644 index 0000000..1468400 --- /dev/null +++ b/src/IdentitySvc/Pages/ExternalLogin/Callback.cshtml.cs @@ -0,0 +1,203 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Security.Claims; +using Duende.IdentityServer; +using Duende.IdentityServer.Events; +using Duende.IdentityServer.Services; +using IdentityModel; +using IdentitySvc.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.ExternalLogin; + +[AllowAnonymous] +[SecurityHeaders] +public class Callback : PageModel +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IIdentityServerInteractionService _interaction; + private readonly ILogger _logger; + private readonly IEventService _events; + + public Callback( + IIdentityServerInteractionService interaction, + IEventService events, + ILogger logger, + UserManager userManager, + SignInManager signInManager) + { + _userManager = userManager; + _signInManager = signInManager; + _interaction = interaction; + _logger = logger; + _events = events; + } + + public async Task OnGet() + { + // read external identity from the temporary cookie + var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); + if (result.Succeeded != true) + { + throw new InvalidOperationException($"External authentication error: { result.Failure }"); + } + + var externalUser = result.Principal ?? + throw new InvalidOperationException("External authentication produced a null Principal"); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + var externalClaims = externalUser.Claims.Select(c => $"{c.Type}: {c.Value}"); + _logger.ExternalClaims(externalClaims); + } + + // lookup our user and external provider info + // try to determine the unique id of the external user (issued by the provider) + // the most common claim type for that are the sub claim and the NameIdentifier + // depending on the external provider, some other claim type might be used + var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ?? + externalUser.FindFirst(ClaimTypes.NameIdentifier) ?? + throw new InvalidOperationException("Unknown userid"); + + var provider = result.Properties.Items["scheme"] ?? throw new InvalidOperationException("Null scheme in authentiation properties"); + var providerUserId = userIdClaim.Value; + + // find external user + var user = await _userManager.FindByLoginAsync(provider, providerUserId); + if (user == null) + { + // this might be where you might initiate a custom workflow for user registration + // in this sample we don't show how that would be done, as our sample implementation + // simply auto-provisions new external user + user = await AutoProvisionUserAsync(provider, providerUserId, externalUser.Claims); + } + + // this allows us to collect any additional claims or properties + // for the specific protocols used and store them in the local auth cookie. + // this is typically used to store data needed for signout from those protocols. + var additionalLocalClaims = new List(); + var localSignInProps = new AuthenticationProperties(); + CaptureExternalLoginContext(result, additionalLocalClaims, localSignInProps); + + // issue authentication cookie for user + await _signInManager.SignInWithClaimsAsync(user, localSignInProps, additionalLocalClaims); + + // delete temporary cookie used during external authentication + await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); + + // retrieve return URL + var returnUrl = result.Properties.Items["returnUrl"] ?? "~/"; + + // check if external login is in the context of an OIDC request + var context = await _interaction.GetAuthorizationContextAsync(returnUrl); + await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.Id, user.UserName, true, context?.Client.ClientId)); + Telemetry.Metrics.UserLogin(context?.Client.ClientId, provider!); + + if (context != null) + { + if (context.IsNativeClient()) + { + // The client is native, so this change in how to + // return the response is for better UX for the end user. + return this.LoadingPage(returnUrl); + } + } + + return Redirect(returnUrl); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1851:Possible multiple enumerations of 'IEnumerable' collection", Justification = "")] + private async Task AutoProvisionUserAsync(string provider, string providerUserId, IEnumerable claims) + { + var sub = Guid.NewGuid().ToString(); + + var user = new ApplicationUser + { + Id = sub, + UserName = sub, // don't need a username, since the user will be using an external provider to login + }; + + // email + var email = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Email)?.Value ?? + claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value; + if (email != null) + { + user.Email = email; + } + + // create a list of claims that we want to transfer into our store + var filtered = new List(); + + // user's display name + var name = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Name)?.Value ?? + claims.FirstOrDefault(x => x.Type == ClaimTypes.Name)?.Value; + if (name != null) + { + filtered.Add(new Claim(JwtClaimTypes.Name, name)); + } + else + { + var first = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.GivenName)?.Value ?? + claims.FirstOrDefault(x => x.Type == ClaimTypes.GivenName)?.Value; + var last = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.FamilyName)?.Value ?? + claims.FirstOrDefault(x => x.Type == ClaimTypes.Surname)?.Value; + if (first != null && last != null) + { + filtered.Add(new Claim(JwtClaimTypes.Name, first + " " + last)); + } + else if (first != null) + { + filtered.Add(new Claim(JwtClaimTypes.Name, first)); + } + else if (last != null) + { + filtered.Add(new Claim(JwtClaimTypes.Name, last)); + } + } + + var identityResult = await _userManager.CreateAsync(user); + if (!identityResult.Succeeded) throw new InvalidOperationException(identityResult.Errors.First().Description); + + if (filtered.Count != 0) + { + identityResult = await _userManager.AddClaimsAsync(user, filtered); + if (!identityResult.Succeeded) throw new InvalidOperationException(identityResult.Errors.First().Description); + } + + identityResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(provider, providerUserId, provider)); + if (!identityResult.Succeeded) throw new InvalidOperationException(identityResult.Errors.First().Description); + + return user; + } + + // if the external login is OIDC-based, there are certain things we need to preserve to make logout work + // this will be different for WS-Fed, SAML2p or other protocols + private static void CaptureExternalLoginContext(AuthenticateResult externalResult, List localClaims, AuthenticationProperties localSignInProps) + { + ArgumentNullException.ThrowIfNull(externalResult.Principal, nameof(externalResult.Principal)); + + // capture the idp used to login, so the session knows where the user came from + localClaims.Add(new Claim(JwtClaimTypes.IdentityProvider, externalResult.Properties?.Items["scheme"] ?? "unknown identity provider")); + + // if the external system sent a session id claim, copy it over + // so we can use it for single sign-out + var sid = externalResult.Principal.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId); + if (sid != null) + { + localClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value)); + } + + // if the external provider issued an id_token, we'll keep it for signout + var idToken = externalResult.Properties?.GetTokenValue("id_token"); + if (idToken != null) + { + localSignInProps.StoreTokens(new[] { new AuthenticationToken { Name = "id_token", Value = idToken } }); + } + } +} diff --git a/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml b/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml new file mode 100644 index 0000000..791f8fa --- /dev/null +++ b/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml @@ -0,0 +1,19 @@ +@page +@model IdentitySvc.Pages.ExternalLogin.Challenge + +@{ + Layout = null; +} + + + + + + + + +
    + +
    + + \ No newline at end of file diff --git a/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml.cs b/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml.cs new file mode 100644 index 0000000..17f1d23 --- /dev/null +++ b/src/IdentitySvc/Pages/ExternalLogin/Challenge.cshtml.cs @@ -0,0 +1,48 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.ExternalLogin; + +[AllowAnonymous] +[SecurityHeaders] +public class Challenge : PageModel +{ + private readonly IIdentityServerInteractionService _interactionService; + + public Challenge(IIdentityServerInteractionService interactionService) + { + _interactionService = interactionService; + } + + public IActionResult OnGet(string scheme, string? returnUrl) + { + if (string.IsNullOrEmpty(returnUrl)) returnUrl = "~/"; + + // validate returnUrl - either it is a valid OIDC URL or back to a local page + if (Url.IsLocalUrl(returnUrl) == false && _interactionService.IsValidReturnUrl(returnUrl) == false) + { + // user might have clicked on a malicious link - should be logged + throw new ArgumentException("invalid return URL"); + } + + // start challenge and roundtrip the return URL and scheme + var props = new AuthenticationProperties + { + RedirectUri = Url.Page("/externallogin/callback"), + + Items = + { + { "returnUrl", returnUrl }, + { "scheme", scheme }, + } + }; + + return Challenge(props, scheme); + } +} diff --git a/src/IdentitySvc/Pages/Grants/Index.cshtml b/src/IdentitySvc/Pages/Grants/Index.cshtml new file mode 100644 index 0000000..a8be8a6 --- /dev/null +++ b/src/IdentitySvc/Pages/Grants/Index.cshtml @@ -0,0 +1,90 @@ +@page +@model IdentitySvc.Pages.Grants.Index +@{ +} + +
    +
    +

    Client Application Permissions

    +

    Below is the list of applications you have given permission to and the resources they have access to.

    +
    + + @if (!Model.View.Grants.Any()) + { +
    +
    +
    + You have not given access to any applications +
    +
    +
    + } + else + { + foreach (var grant in Model.View.Grants) + { +
    +
    +
    +
    + @if (grant.ClientLogoUrl != null) + { + + } + @grant.ClientName +
    + +
    +
    + + +
    +
    +
    +
    + +
      + @if (grant.Description != null) + { +
    • + @grant.Description +
    • + } +
    • + @grant.Created.ToString("yyyy-MM-dd") +
    • + @if (grant.Expires.HasValue) + { +
    • + @grant.Expires.Value.ToString("yyyy-MM-dd") +
    • + } + @if (grant.IdentityGrantNames.Any()) + { +
    • + +
        + @foreach (var name in grant.IdentityGrantNames) + { +
      • @name
      • + } +
      +
    • + } + @if (grant.ApiGrantNames.Any()) + { +
    • + +
        + @foreach (var name in grant.ApiGrantNames) + { +
      • @name
      • + } +
      +
    • + } +
    +
    + } + } +
    \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Grants/Index.cshtml.cs b/src/IdentitySvc/Pages/Grants/Index.cshtml.cs new file mode 100644 index 0000000..ddef621 --- /dev/null +++ b/src/IdentitySvc/Pages/Grants/Index.cshtml.cs @@ -0,0 +1,82 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Events; +using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Grants; + +[SecurityHeaders] +[Authorize] +public class Index : PageModel +{ + private readonly IIdentityServerInteractionService _interaction; + private readonly IClientStore _clients; + private readonly IResourceStore _resources; + private readonly IEventService _events; + + public Index(IIdentityServerInteractionService interaction, + IClientStore clients, + IResourceStore resources, + IEventService events) + { + _interaction = interaction; + _clients = clients; + _resources = resources; + _events = events; + } + + public ViewModel View { get; set; } = default!; + + public async Task OnGet() + { + var grants = await _interaction.GetAllUserGrantsAsync(); + + var list = new List(); + foreach (var grant in grants) + { + var client = await _clients.FindClientByIdAsync(grant.ClientId); + if (client != null) + { + var resources = await _resources.FindResourcesByScopeAsync(grant.Scopes); + + var item = new GrantViewModel() + { + ClientId = client.ClientId, + ClientName = client.ClientName ?? client.ClientId, + ClientLogoUrl = client.LogoUri, + ClientUrl = client.ClientUri, + Description = grant.Description, + Created = grant.CreationTime, + Expires = grant.Expiration, + IdentityGrantNames = resources.IdentityResources.Select(x => x.DisplayName ?? x.Name).ToArray(), + ApiGrantNames = resources.ApiScopes.Select(x => x.DisplayName ?? x.Name).ToArray() + }; + + list.Add(item); + } + } + + View = new ViewModel + { + Grants = list + }; + } + + [BindProperty] + public string? ClientId { get; set; } + + public async Task OnPost() + { + await _interaction.RevokeUserConsentAsync(ClientId); + await _events.RaiseAsync(new GrantsRevokedEvent(User.GetSubjectId(), ClientId)); + Telemetry.Metrics.GrantsRevoked(ClientId); + + return RedirectToPage("/Grants/Index"); + } +} diff --git a/src/IdentitySvc/Pages/Grants/ViewModel.cs b/src/IdentitySvc/Pages/Grants/ViewModel.cs new file mode 100644 index 0000000..fb21c2b --- /dev/null +++ b/src/IdentitySvc/Pages/Grants/ViewModel.cs @@ -0,0 +1,22 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages.Grants; + +public class ViewModel +{ + public IEnumerable Grants { get; set; } = Enumerable.Empty(); +} + +public class GrantViewModel +{ + public string? ClientId { get; set; } + public string? ClientName { get; set; } + public string? ClientUrl { get; set; } + public string? ClientLogoUrl { get; set; } + public string? Description { get; set; } + public DateTime Created { get; set; } + public DateTime? Expires { get; set; } + public IEnumerable IdentityGrantNames { get; set; } = Enumerable.Empty(); + public IEnumerable ApiGrantNames { get; set; } = Enumerable.Empty(); +} diff --git a/src/IdentitySvc/Pages/Home/Error/Index.cshtml b/src/IdentitySvc/Pages/Home/Error/Index.cshtml new file mode 100644 index 0000000..b1c2dd5 --- /dev/null +++ b/src/IdentitySvc/Pages/Home/Error/Index.cshtml @@ -0,0 +1,35 @@ +@page +@model IdentitySvc.Pages.Error.Index + +
    +
    +

    Error

    +
    + +
    +
    +
    + Sorry, there was an error + + @if (Model.View.Error != null) + { + + + : @Model.View.Error.Error + + + + if (Model.View.Error.ErrorDescription != null) + { +
    @Model.View.Error.ErrorDescription
    + } + } +
    + + @if (Model?.View?.Error?.RequestId != null) + { +
    Request Id: @Model.View.Error.RequestId
    + } +
    +
    +
    diff --git a/src/IdentitySvc/Pages/Home/Error/Index.cshtml.cs b/src/IdentitySvc/Pages/Home/Error/Index.cshtml.cs new file mode 100644 index 0000000..f100b69 --- /dev/null +++ b/src/IdentitySvc/Pages/Home/Error/Index.cshtml.cs @@ -0,0 +1,40 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Error; + +[AllowAnonymous] +[SecurityHeaders] +public class Index : PageModel +{ + private readonly IIdentityServerInteractionService _interaction; + private readonly IWebHostEnvironment _environment; + + public ViewModel View { get; set; } = new(); + + public Index(IIdentityServerInteractionService interaction, IWebHostEnvironment environment) + { + _interaction = interaction; + _environment = environment; + } + + public async Task OnGet(string? errorId) + { + // retrieve error details from identityserver + var message = await _interaction.GetErrorContextAsync(errorId); + if (message != null) + { + View.Error = message; + + if (!_environment.IsDevelopment()) + { + // only show in development + message.ErrorDescription = null; + } + } + } +} diff --git a/src/IdentitySvc/Pages/Home/Error/ViewModel.cs b/src/IdentitySvc/Pages/Home/Error/ViewModel.cs new file mode 100644 index 0000000..88fa1a4 --- /dev/null +++ b/src/IdentitySvc/Pages/Home/Error/ViewModel.cs @@ -0,0 +1,20 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; + +namespace IdentitySvc.Pages.Error; + +public class ViewModel +{ + public ViewModel() + { + } + + public ViewModel(string error) + { + Error = new ErrorMessage { Error = error }; + } + + public ErrorMessage? Error { get; set; } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/IdentityServerSuppressions.cs b/src/IdentitySvc/Pages/IdentityServerSuppressions.cs new file mode 100644 index 0000000..7784a9a --- /dev/null +++ b/src/IdentitySvc/Pages/IdentityServerSuppressions.cs @@ -0,0 +1,22 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +// global/shared +[assembly: SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Consistent with the IdentityServer APIs")] +[assembly: SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "Consistent with the IdentityServer APIs")] +[assembly: SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "No need for ConfigureAwait in ASP.NET Core application code, as there is no SynchronizationContext.")] + +// page specific +[assembly: SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "TestUsers are not designed to be extended", Scope = "member", Target = "~P:IdentitySvc.TestUsers.Users")] +[assembly: SuppressMessage("Design", "CA1034:Nested types should not be visible", Justification = "ExternalProvider is nested by design", Scope = "type", Target = "~T:IdentitySvc.Pages.Login.ViewModel.ExternalProvider")] +[assembly: SuppressMessage("Naming", "CA1716:Identifiers should not match keywords", Justification = "This namespace is just for organization, and won't be referenced elsewhere", Scope = "namespace", Target = "~N:IdentitySvc.Pages.Error")] +[assembly: SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Namespaces of pages are not likely to be used elsewhere, so there is little chance of confusion", Scope = "type", Target = "~T:IdentitySvc.Pages.Ciba.Consent")] +[assembly: SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Namespaces of pages are not likely to be used elsewhere, so there is little chance of confusion", Scope = "type", Target = "~T:IdentitySvc.Pages.Extensions")] +[assembly: SuppressMessage("Performance", "CA1805:Do not initialize unnecessarily", Justification = "This is for clarity and consistency with the surrounding code", Scope = "member", Target = "~F:IdentitySvc.Pages.Logout.LogoutOptions.AutomaticRedirectAfterSignOut")] diff --git a/src/IdentitySvc/Pages/Index.cshtml b/src/IdentitySvc/Pages/Index.cshtml new file mode 100644 index 0000000..65465c4 --- /dev/null +++ b/src/IdentitySvc/Pages/Index.cshtml @@ -0,0 +1,46 @@ +@page +@model IdentitySvc.Pages.Home.Index + +
    +

    + + Welcome to Duende IdentityServer + (version @Model.Version) +

    + +
      +
    • + IdentityServer publishes a + discovery document + where you can find metadata and links to all the endpoints, key material, etc. +
    • +
    • + Click here to see the claims for your current session. +
    • +
    • + Click here to manage your stored grants. +
    • +
    • + Click here to view the server side sessions. +
    • +
    • + Click here to view your pending CIBA login requests. +
    • +
    • + Here are links to the + source code repository, + and ready to use samples. +
    • +
    + + @if(Model.License != null) + { +

    License

    +
    +
    Serial Number
    +
    @Model.License.SerialNumber
    +
    Expiration
    +
    @Model.License.Expiration!.Value.ToLongDateString()
    +
    + } +
    diff --git a/src/IdentitySvc/Pages/Index.cshtml.cs b/src/IdentitySvc/Pages/Index.cshtml.cs new file mode 100644 index 0000000..12d1f1a --- /dev/null +++ b/src/IdentitySvc/Pages/Index.cshtml.cs @@ -0,0 +1,27 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer; +using System.Reflection; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Home; + +[AllowAnonymous] +public class Index : PageModel +{ + public Index(IdentityServerLicense? license = null) + { + License = license; + } + + public string Version + { + get => typeof(Duende.IdentityServer.Hosting.IdentityServerMiddleware).Assembly + .GetCustomAttribute() + ?.InformationalVersion.Split('+').First() + ?? "unavailable"; + } + public IdentityServerLicense? License { get; } +} diff --git a/src/IdentitySvc/Pages/Log.cs b/src/IdentitySvc/Pages/Log.cs new file mode 100644 index 0000000..4254325 --- /dev/null +++ b/src/IdentitySvc/Pages/Log.cs @@ -0,0 +1,87 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +namespace IdentitySvc.Pages; + +internal static class Log +{ + private static readonly Action _invalidId = LoggerMessage.Define( + LogLevel.Error, + EventIds.InvalidId, + "Invalid id {Id}"); + + public static void InvalidId(this ILogger logger, string? id) + { + _invalidId(logger, id, null); + } + + private static readonly Action _invalidBackchannelLoginId = LoggerMessage.Define( + LogLevel.Warning, + EventIds.InvalidBackchannelLoginId, + "Invalid backchannel login id {Id}"); + + public static void InvalidBackchannelLoginId(this ILogger logger, string? id) + { + _invalidBackchannelLoginId(logger, id, null); + } + + private static Action, Exception?> _externalClaims = LoggerMessage.Define>( + LogLevel.Debug, + EventIds.ExternalClaims, + "External claims: {Claims}"); + + public static void ExternalClaims(this ILogger logger, IEnumerable claims) + { + _externalClaims(logger, claims, null); + } + + private static Action _noMatchingBackchannelLoginRequest = LoggerMessage.Define( + LogLevel.Error, + EventIds.NoMatchingBackchannelLoginRequest, + "No backchannel login request matching id: {Id}"); + + public static void NoMatchingBackchannelLoginRequest(this ILogger logger, string id) + { + _noMatchingBackchannelLoginRequest(logger, id, null); + } + + private static Action _noConsentMatchingRequest = LoggerMessage.Define( + LogLevel.Error, + EventIds.NoConsentMatchingRequest, + "No consent request matching request: {ReturnUrl}"); + + public static void NoConsentMatchingRequest(this ILogger logger, string returnUrl) + { + _noConsentMatchingRequest(logger, returnUrl, null); + } + + +} + +internal static class EventIds +{ + private const int UIEventsStart = 10000; + + ////////////////////////////// + // Consent + ////////////////////////////// + private const int ConsentEventsStart = UIEventsStart + 1000; + public const int InvalidId = ConsentEventsStart + 0; + public const int NoConsentMatchingRequest = ConsentEventsStart + 1; + + ////////////////////////////// + // External Login + ////////////////////////////// + private const int ExternalLoginEventsStart = UIEventsStart + 2000; + public const int ExternalClaims = ExternalLoginEventsStart + 0; + + ////////////////////////////// + // CIBA + ////////////////////////////// + private const int CibaEventsStart = UIEventsStart + 3000; + public const int InvalidBackchannelLoginId = CibaEventsStart + 0; + public const int NoMatchingBackchannelLoginRequest = CibaEventsStart + 1; + + + +} diff --git a/src/IdentitySvc/Pages/Redirect/Index.cshtml b/src/IdentitySvc/Pages/Redirect/Index.cshtml new file mode 100644 index 0000000..4dd58ee --- /dev/null +++ b/src/IdentitySvc/Pages/Redirect/Index.cshtml @@ -0,0 +1,14 @@ +@page +@model IdentitySvc.Pages.Redirect.IndexModel +@{ +} + +
    +
    +

    You are now being returned to the application

    +

    Once complete, you may close this tab.

    +
    +
    + + + diff --git a/src/IdentitySvc/Pages/Redirect/Index.cshtml.cs b/src/IdentitySvc/Pages/Redirect/Index.cshtml.cs new file mode 100644 index 0000000..b128d75 --- /dev/null +++ b/src/IdentitySvc/Pages/Redirect/Index.cshtml.cs @@ -0,0 +1,25 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.Redirect; + +[AllowAnonymous] +public class IndexModel : PageModel +{ + public string? RedirectUri { get; set; } + + public IActionResult OnGet(string? redirectUri) + { + if (!Url.IsLocalUrl(redirectUri)) + { + return RedirectToPage("/Home/Error/Index"); + } + + RedirectUri = redirectUri; + return Page(); + } +} diff --git a/src/IdentitySvc/Pages/SecurityHeadersAttribute.cs b/src/IdentitySvc/Pages/SecurityHeadersAttribute.cs new file mode 100644 index 0000000..684acf2 --- /dev/null +++ b/src/IdentitySvc/Pages/SecurityHeadersAttribute.cs @@ -0,0 +1,56 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages; + +public sealed class SecurityHeadersAttribute : ActionFilterAttribute +{ + public override void OnResultExecuting(ResultExecutingContext context) + { + ArgumentNullException.ThrowIfNull(context, nameof(context)); + + var result = context.Result; + if (result is PageResult) + { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + if (!context.HttpContext.Response.Headers.ContainsKey("X-Content-Type-Options")) + { + context.HttpContext.Response.Headers.Append("X-Content-Type-Options", "nosniff"); + } + + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options + if (!context.HttpContext.Response.Headers.ContainsKey("X-Frame-Options")) + { + context.HttpContext.Response.Headers.Append("X-Frame-Options", "DENY"); + } + + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + var csp = "default-src 'self'; object-src 'none'; frame-ancestors 'none'; sandbox allow-forms allow-same-origin allow-scripts; base-uri 'self';"; + // also consider adding upgrade-insecure-requests once you have HTTPS in place for production + //csp += "upgrade-insecure-requests;"; + // also an example if you need client images to be displayed from twitter + // csp += "img-src 'self' https://pbs.twimg.com;"; + + // once for standards compliant browsers + if (!context.HttpContext.Response.Headers.ContainsKey("Content-Security-Policy")) + { + context.HttpContext.Response.Headers.Append("Content-Security-Policy", csp); + } + // and once again for IE + if (!context.HttpContext.Response.Headers.ContainsKey("X-Content-Security-Policy")) + { + context.HttpContext.Response.Headers.Append("X-Content-Security-Policy", csp); + } + + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy + var referrer_policy = "no-referrer"; + if (!context.HttpContext.Response.Headers.ContainsKey("Referrer-Policy")) + { + context.HttpContext.Response.Headers.Append("Referrer-Policy", referrer_policy); + } + } + } +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/ServerSideSessions/Index.cshtml b/src/IdentitySvc/Pages/ServerSideSessions/Index.cshtml new file mode 100644 index 0000000..880cacf --- /dev/null +++ b/src/IdentitySvc/Pages/ServerSideSessions/Index.cshtml @@ -0,0 +1,147 @@ +@page +@model IdentitySvc.Pages.ServerSideSessions.IndexModel + +
    +
    +
    +
    +
    +

    User Sessions

    +
    + +
    + + @if (Model.UserSessions != null) + { +
    +
    + @if (Model.UserSessions.HasPrevResults) + { + Prev + } +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    + @if (Model.UserSessions.HasNextResults) + { + Next + } +
    +
    + + @if (Model.UserSessions.TotalCount.HasValue) + { +
    + @if (Model.UserSessions.CurrentPage.HasValue && Model.UserSessions.TotalPages.HasValue) + { + + Total Results: @Model.UserSessions.TotalCount, + Page @Model.UserSessions.CurrentPage of @Model.UserSessions.TotalPages + + } + else + { + + Total Results: @Model.UserSessions.TotalCount + + } +
    + } + +
    + + @if (Model.UserSessions.Results.Any()) + { +
    + + + + + + + + + + + + + @foreach (var session in Model.UserSessions.Results) + { + + + + + + + + + + } + +
    Subject IdSession IdDisplay NameCreatedExpires
    @session.SubjectId@session.SessionId@session.DisplayName@session.Created@session.Expires +
    + + +
    +
    + Clients: + @if (session.ClientIds?.Any() == true) + { + @(session.ClientIds.Aggregate((x, y) => $"{x}, {y}")) + } + else + { + @("None") + } +
    +
    + } + else + { +
    No User Sessions
    + } + } + else + { +
    +
    + You do not have server-side sessions enabled. + To do so, use AddServerSideSessions on your IdentityServer configuration. + See the documentation for more information. +
    +
    + } +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/src/IdentitySvc/Pages/ServerSideSessions/Index.cshtml.cs b/src/IdentitySvc/Pages/ServerSideSessions/Index.cshtml.cs new file mode 100644 index 0000000..3045f48 --- /dev/null +++ b/src/IdentitySvc/Pages/ServerSideSessions/Index.cshtml.cs @@ -0,0 +1,67 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySvc.Pages.ServerSideSessions +{ + public class IndexModel : PageModel + { + private readonly ISessionManagementService? _sessionManagementService; + + public IndexModel(ISessionManagementService? sessionManagementService = null) + { + _sessionManagementService = sessionManagementService; + } + + public QueryResult? UserSessions { get; set; } + + [BindProperty(SupportsGet = true)] + public string? DisplayNameFilter { get; set; } + + [BindProperty(SupportsGet = true)] + public string? SessionIdFilter { get; set; } + + [BindProperty(SupportsGet = true)] + public string? SubjectIdFilter { get; set; } + + [BindProperty(SupportsGet = true)] + public string? Token { get; set; } + + [BindProperty(SupportsGet = true)] + public string? Prev { get; set; } + + public async Task OnGet() + { + if (_sessionManagementService != null) + { + UserSessions = await _sessionManagementService.QuerySessionsAsync(new SessionQuery + { + ResultsToken = Token, + RequestPriorResults = Prev == "true", + DisplayName = DisplayNameFilter, + SessionId = SessionIdFilter, + SubjectId = SubjectIdFilter + }); + } + } + + [BindProperty] + public string? SessionId { get; set; } + + public async Task OnPost() + { + ArgumentNullException.ThrowIfNull(_sessionManagementService); + + await _sessionManagementService.RemoveSessionsAsync(new RemoveSessionsContext { + SessionId = SessionId, + }); + return RedirectToPage("/ServerSideSessions/Index", new { Token, DisplayNameFilter, SessionIdFilter, SubjectIdFilter, Prev }); + } + } +} + diff --git a/src/IdentitySvc/Pages/Shared/_Layout.cshtml b/src/IdentitySvc/Pages/Shared/_Layout.cshtml new file mode 100644 index 0000000..067a0f0 --- /dev/null +++ b/src/IdentitySvc/Pages/Shared/_Layout.cshtml @@ -0,0 +1,29 @@ + + + + + + + + Duende IdentityServer + + + + + + + + + + + +
    + @RenderBody() +
    + + + + + @RenderSection("scripts", required: false) + + diff --git a/src/IdentitySvc/Pages/Shared/_Nav.cshtml b/src/IdentitySvc/Pages/Shared/_Nav.cshtml new file mode 100644 index 0000000..e678cf1 --- /dev/null +++ b/src/IdentitySvc/Pages/Shared/_Nav.cshtml @@ -0,0 +1,33 @@ +@using Duende.IdentityServer.Extensions +@{ + #nullable enable + string? name = null; + if (!true.Equals(ViewData["signed-out"])) + { + name = Context.User?.GetDisplayName(); + } +} + + diff --git a/src/IdentitySvc/Pages/Shared/_ValidationSummary.cshtml b/src/IdentitySvc/Pages/Shared/_ValidationSummary.cshtml new file mode 100644 index 0000000..674d68d --- /dev/null +++ b/src/IdentitySvc/Pages/Shared/_ValidationSummary.cshtml @@ -0,0 +1,7 @@ +@if (ViewContext.ModelState.IsValid == false) +{ +
    + Error +
    +
    +} \ No newline at end of file diff --git a/src/IdentitySvc/Pages/Telemetry.cs b/src/IdentitySvc/Pages/Telemetry.cs new file mode 100644 index 0000000..7690cf2 --- /dev/null +++ b/src/IdentitySvc/Pages/Telemetry.cs @@ -0,0 +1,142 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +using System.Diagnostics.Metrics; + +namespace IdentitySvc.Pages; + +#pragma warning disable CA1034 // Nested types should not be visible +#pragma warning disable CA1724 // Type names should not match namespaces + +/// +/// Telemetry helpers for the UI +/// +public static class Telemetry +{ + private static readonly string ServiceVersion = typeof(Telemetry).Assembly.GetName().Version!.ToString(); + + /// + /// Service name for telemetry. + /// + public static readonly string ServiceName = typeof(Telemetry).Assembly.GetName().Name!; + + /// + /// Metrics configuration + /// + public static class Metrics + { +#pragma warning disable 1591 + + /// + /// Name of Counters + /// + public static class Counters + { + public const string Consent = "tokenservice.consent"; + public const string GrantsRevoked = "tokenservice.grants_revoked"; + public const string UserLogin = "tokenservice.user_login"; + public const string UserLogout = "tokenservice.user_logout"; + } + + /// + /// Name of tags + /// + public static class Tags + { + public const string Client = "client"; + public const string Error = "error"; + public const string Idp = "idp"; + public const string Remember = "remember"; + public const string Scope = "scope"; + public const string Consent = "consent"; + } + + /// + /// Values of tags + /// + public static class TagValues + { + public const string Granted = "granted"; + public const string Denied = "denied"; + } + +#pragma warning restore 1591 + + /// + /// Meter for the IdentityServer host project + /// + private static readonly Meter Meter = new Meter(ServiceName, ServiceVersion); + + private static Counter ConsentCounter = Meter.CreateCounter(Counters.Consent); + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentGranted(string clientId, IEnumerable scopes, bool remember) + { + ArgumentNullException.ThrowIfNull(scopes); + + foreach (var scope in scopes) + { + ConsentCounter.Add(1, + new(Tags.Client, clientId), + new(Tags.Scope, scope), + new(Tags.Remember, remember), + new(Tags.Consent, TagValues.Granted)); + } + } + + /// + /// Helper method to increase counter. The scopes + /// are expanded and called one by one to not cause a combinatory explosion of scopes. + /// + /// Client id + /// Scope names. Each element is added on it's own to the counter + public static void ConsentDenied(string clientId, IEnumerable scopes) + { + ArgumentNullException.ThrowIfNull(scopes); + foreach (var scope in scopes) + { + ConsentCounter.Add(1, new(Tags.Client, clientId), new(Tags.Scope, scope), new(Tags.Consent, TagValues.Denied)); + } + } + + private static Counter GrantsRevokedCounter = Meter.CreateCounter(Counters.GrantsRevoked); + + /// + /// Helper method to increase the counter. + /// + /// Client id to revoke for, or null for all. + public static void GrantsRevoked(string? clientId) + => GrantsRevokedCounter.Add(1, tag: new(Tags.Client, clientId)); + + private static Counter UserLoginCounter = Meter.CreateCounter(Counters.UserLogin); + + /// + /// Helper method to increase counter. + /// + /// Client Id, if available + public static void UserLogin(string? clientId, string idp) + => UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp)); + + /// + /// Helper method to increase + /// Client Id, if available + /// Error message + public static void UserLoginFailure(string? clientId, string idp, string error) + => UserLoginCounter.Add(1, new(Tags.Client, clientId), new(Tags.Idp, idp), new(Tags.Error, error)); + + private static Counter UserLogoutCounter = Meter.CreateCounter(Counters.UserLogout); + + /// + /// Helper method to increase the counter. + /// + /// Idp/authentication scheme for external authentication, or "local" for built in. + public static void UserLogout(string? idp) + => UserLogoutCounter.Add(1, tag: new(Tags.Idp, idp)); + } +} diff --git a/src/IdentitySvc/Pages/_ViewImports.cshtml b/src/IdentitySvc/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..01b4e92 --- /dev/null +++ b/src/IdentitySvc/Pages/_ViewImports.cshtml @@ -0,0 +1,2 @@ +@using IdentitySvc.Pages +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/IdentitySvc/Pages/_ViewStart.cshtml b/src/IdentitySvc/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..a5f1004 --- /dev/null +++ b/src/IdentitySvc/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/src/IdentitySvc/Program.cs b/src/IdentitySvc/Program.cs new file mode 100644 index 0000000..f7e259c --- /dev/null +++ b/src/IdentitySvc/Program.cs @@ -0,0 +1,43 @@ +using IdentitySvc; +using Serilog; + +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +Log.Information("Starting up"); + +try +{ + var builder = WebApplication.CreateBuilder(args); + + builder.Host.UseSerilog((ctx, lc) => lc + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}") + .Enrich.FromLogContext() + .ReadFrom.Configuration(ctx.Configuration)); + + var app = builder + .ConfigureServices() + .ConfigurePipeline(); + + // this seeding is only for the template to bootstrap the DB and users. + // in production you will likely want a different approach. + if (args.Contains("/seed")) + { + Log.Information("Seeding database..."); + SeedData.EnsureSeedData(app); + Log.Information("Done seeding database. Exiting."); + return; + } + + app.Run(); +} +catch (Exception ex) when (ex is not HostAbortedException) +{ + Log.Fatal(ex, "Unhandled exception"); +} +finally +{ + Log.Information("Shut down complete"); + Log.CloseAndFlush(); +} \ No newline at end of file diff --git a/src/IdentitySvc/Properties/launchSettings.json b/src/IdentitySvc/Properties/launchSettings.json new file mode 100644 index 0000000..6f874f1 --- /dev/null +++ b/src/IdentitySvc/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "SelfHost": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001" + } + } +} \ No newline at end of file diff --git a/src/IdentitySvc/SeedData.cs b/src/IdentitySvc/SeedData.cs new file mode 100644 index 0000000..f9a24b2 --- /dev/null +++ b/src/IdentitySvc/SeedData.cs @@ -0,0 +1,87 @@ +using System.Security.Claims; +using IdentityModel; +using IdentitySvc.Data; +using IdentitySvc.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace IdentitySvc; + +public class SeedData +{ + public static void EnsureSeedData(WebApplication app) + { + using (var scope = app.Services.GetRequiredService().CreateScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + context.Database.Migrate(); + + var userMgr = scope.ServiceProvider.GetRequiredService>(); + var alice = userMgr.FindByNameAsync("alice").Result; + if (alice == null) + { + alice = new ApplicationUser + { + UserName = "alice", + Email = "AliceSmith@email.com", + EmailConfirmed = true, + }; + var result = userMgr.CreateAsync(alice, "Pass123$").Result; + if (!result.Succeeded) + { + throw new Exception(result.Errors.First().Description); + } + + result = userMgr.AddClaimsAsync(alice, new Claim[]{ + new Claim(JwtClaimTypes.Name, "Alice Smith"), + new Claim(JwtClaimTypes.GivenName, "Alice"), + new Claim(JwtClaimTypes.FamilyName, "Smith"), + new Claim(JwtClaimTypes.WebSite, "http://alice.com"), + }).Result; + if (!result.Succeeded) + { + throw new Exception(result.Errors.First().Description); + } + Log.Debug("alice created"); + } + else + { + Log.Debug("alice already exists"); + } + + var bob = userMgr.FindByNameAsync("bob").Result; + if (bob == null) + { + bob = new ApplicationUser + { + UserName = "bob", + Email = "BobSmith@email.com", + EmailConfirmed = true + }; + var result = userMgr.CreateAsync(bob, "Pass123$").Result; + if (!result.Succeeded) + { + throw new Exception(result.Errors.First().Description); + } + + result = userMgr.AddClaimsAsync(bob, new Claim[]{ + new Claim(JwtClaimTypes.Name, "Bob Smith"), + new Claim(JwtClaimTypes.GivenName, "Bob"), + new Claim(JwtClaimTypes.FamilyName, "Smith"), + new Claim(JwtClaimTypes.WebSite, "http://bob.com"), + new Claim("location", "somewhere") + }).Result; + if (!result.Succeeded) + { + throw new Exception(result.Errors.First().Description); + } + Log.Debug("bob created"); + } + else + { + Log.Debug("bob already exists"); + } + } + } +} diff --git a/src/IdentitySvc/buildschema.bat b/src/IdentitySvc/buildschema.bat new file mode 100644 index 0000000..026d29a --- /dev/null +++ b/src/IdentitySvc/buildschema.bat @@ -0,0 +1,3 @@ +rmdir /S /Q "Data/Migrations" + +dotnet ef migrations add Users -c ApplicationDbContext -o Data/Migrations diff --git a/src/IdentitySvc/buildschema.sh b/src/IdentitySvc/buildschema.sh new file mode 100644 index 0000000..50250dc --- /dev/null +++ b/src/IdentitySvc/buildschema.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +rm -rf "Data/Migrations" + +dotnet ef migrations add Users -c ApplicationDbContext -o Data/Migrations diff --git a/src/IdentitySvc/wwwroot/css/site.css b/src/IdentitySvc/wwwroot/css/site.css new file mode 100644 index 0000000..f02c0b1 --- /dev/null +++ b/src/IdentitySvc/wwwroot/css/site.css @@ -0,0 +1,39 @@ +.welcome-page .logo { + width: 64px; +} + +.icon-banner { + width: 32px; +} + +.body-container { + margin-top: 60px; + padding-bottom: 40px; +} + +.welcome-page li { + list-style: none; + padding: 4px; +} + +.logged-out-page iframe { + display: none; + width: 0; + height: 0; +} + +.grants-page .card { + margin-top: 20px; + border-bottom: 1px solid lightgray; +} +.grants-page .card .card-title { + font-size: 120%; + font-weight: bold; +} +.grants-page .card .card-title img { + width: 100px; + height: 100px; +} +.grants-page .card label { + font-weight: bold; +} diff --git a/src/IdentitySvc/wwwroot/css/site.min.css b/src/IdentitySvc/wwwroot/css/site.min.css new file mode 100644 index 0000000..f3e1985 --- /dev/null +++ b/src/IdentitySvc/wwwroot/css/site.min.css @@ -0,0 +1 @@ +.welcome-page .logo{width:64px;}.icon-banner{width:32px;}.body-container{margin-top:60px;padding-bottom:40px;}.welcome-page li{list-style:none;padding:4px;}.logged-out-page iframe{display:none;width:0;height:0;}.grants-page .card{margin-top:20px;border-bottom:1px solid #d3d3d3;}.grants-page .card .card-title{font-size:120%;font-weight:bold;}.grants-page .card .card-title img{width:100px;height:100px;}.grants-page .card label{font-weight:bold;} \ No newline at end of file diff --git a/src/IdentitySvc/wwwroot/css/site.scss b/src/IdentitySvc/wwwroot/css/site.scss new file mode 100644 index 0000000..29d9c85 --- /dev/null +++ b/src/IdentitySvc/wwwroot/css/site.scss @@ -0,0 +1,50 @@ +.welcome-page { + .logo { + width: 64px; + } +} + +.icon-banner { + width: 32px; +} + +.body-container { + margin-top: 60px; + padding-bottom: 40px; +} + +.welcome-page { + li { + list-style: none; + padding: 4px; + } +} + +.logged-out-page { + iframe { + display: none; + width: 0; + height: 0; + } +} + +.grants-page { + .card { + margin-top: 20px; + border-bottom: 1px solid lightgray; + + .card-title { + img { + width: 100px; + height: 100px; + } + + font-size: 120%; + font-weight: bold; + } + + label { + font-weight: bold; + } + } +} diff --git a/src/IdentitySvc/wwwroot/duende-logo.svg b/src/IdentitySvc/wwwroot/duende-logo.svg new file mode 100644 index 0000000..5fa55bf --- /dev/null +++ b/src/IdentitySvc/wwwroot/duende-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/IdentitySvc/wwwroot/favicon.ico b/src/IdentitySvc/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f7ecfd9934cda7e5dfe7f7ada6c2a6e99eb8377d GIT binary patch literal 15406 zcmeHO3v3f*9KV5xPmm}gN@UXOoQR-KWwQ3V-W3D^#m6KtwzYS2f(l{s+S<-ztHfY@ zM54&t+Ce}Rd?r33NW^XJ6oc;=6CcqS#DKvUL4>iLzpveu?sj+Ut=HNFF8SwMzx%%L z_xu0vd;kBVs2plAb-@J`(p+lc85DIBMNzrAvETV4C@O@!Yp?BjpGQ$0BPl8$Wl#k* zV);l+dHfdV)wNf!qXyEL&&uBCw{nu-%KquKvX>cYW7Rm?pXQFs*mCN%u$7s>$6Wq~ zjG35vtxnas%1ll^tCIm{-0q3^eyj6NGqIFyNf>TeOFr(6N!He=d?HfRAY~mhe?#|q z8rYI=oRc*ZOWBq<)^Qee|4|Je`EM!9oBbQQFIUIrI#*(UWHw%6do}soh3NCYtK+kc ziDlFAH*lX)$L0>>yIhhEOu0=T?X=+g9#h9>AC}7{k>-Tm4V7U};9aDx$;yt|5B>0l zYmr1`E5l1`#?Oe3H*or8*{G@KCV?9*5_Si?TKg@n{BYUo_&$v6#fa>jSuNG)-y#8D zNEVxv!yfIR7LHba`#rus_=H2tl(H{NsFK0S;*)s``XAB4(aLWtiS1)$-R1Pn=}RJ{ z&1CUo`Uf^^;b`Tz&wsd&{vTjZy`{KHYPR!kBlsVcio@^?wgBsB#2x5LR$A|@=_Z&& zcFkIb{rIBjo*4VV11*jUa@;fYNTN7sZ&lThWMwm3bv^d5o$?;GcgjCw2z-Erf&X-G z+}ODwXctawV)*L@*lg0(8eLVxf%U5z2b@znjP}K(ffEY0*!VJ4{OB2p=M##i>(3mV zf!Z`U9DKpH2)7dHeVS~-L~u~=i*|l}deLL8<1F@+nhi)_+-R#TNH>SgHHxD7o0WRnHRJqh_JZpf*8PXhH%KL#vNN4p%j-+2Mj;ib8)-a8>m+spSu=$VZ=_-W^S*!YCH?R)OHX!e_Bqb4SO=FXI!#5ab1^Q|uT zm=7!>QrR~Kd={tb{f<%}`0pOflueVxsmtST#NF6_CD%T`Y3_^04ByfBr-L}U;2dyY zhjFRf@1t`P&CTksy-dHa&0{BG%pUAx>)3sCeBQzP?IJyWSf=^u;gPinYt_sBamTkg zgtaT{Z12xw=YW4_68yK3ex_%OOuwt8Z6ndA_x8)(${wk)OrtZ+PY+L|#6fj5cR%?5 zqt72>I?sjWHoG42O4p?rvmfen$9hY%pOF}d>|#$*WFLu`kbe3-`!Dg=WZRxOYI~Yf z_2cyZ9_&{l?HH4)?a5tldzpB7q&&cU#Me7hk%(JlH{3XP>l2gaNU}USVWw1<=SrOC zNvuql*TR*j%3TC^+>!g?r}+*(n*&k*1@U1FP{O6+!CkYL9e;1;Ly{~zZe1$9=-}Z` znRZeK3%5fYUvg$D{NGu;cxb!3VV#y=!EnC2L2A?R#Sov!Yp#Woc;!@L`#5C8etWy~yJhjS(J&*X{FY7YOQ>%RYygFUm6aMedUcPT5?$@cHDGmPTHm_MjxoD^nlnv3oquFsethy>f+Q(7&vw zmqbZ9WIan&NE{{Z`j5!A)=J@v`Vn!1*XwcmzupDyo*L+&?!$}6d-7cv3(q`Jc(*D2 z$V^=~*zX+;{_R?}Kba5ze34LWCbk)Ek@E}UTFb%n?~`16_kTGbF$Q~fXW^YKnd3u@ zXNSyFFKuz~D@Yu!8MZUCjbM>y68xpV>2Z&~vkO0E=~&JQI)rL)?AFtP_{Aa5nz1gM zX@AmI6H8*m1+AEd_?m_?f3y?}MP}ot+Fo=l+k^#(vHwf&8}#&}cBAp@sq#yve&Z~R zWxMcBzx)}#3u{MRGHqtMOrs+j3vlv(4dUxq?|HR_5q>r^4&O{Yv8FA@ntNRDJ763f z4ca`5%)~XTtqu0l(cr&Zmp}f80($z*!Zf>`p12y#bvc{uPi#SAldz_}s`U-enable + + + true + PreserveNewest + PreserveNewest + + + true + PreserveNewest + PreserveNewest + + +