├── .aspire └── settings.json ├── Todo.Web ├── Client │ ├── wwwroot │ │ ├── bg.png │ │ ├── favicon.ico │ │ ├── bootstrap │ │ │ ├── LICENSE │ │ │ └── dist │ │ │ │ └── css │ │ │ │ ├── bootstrap-reboot.min.css │ │ │ │ ├── bootstrap-reboot.rtl.min.css │ │ │ │ ├── bootstrap-reboot.rtl.css │ │ │ │ └── bootstrap-reboot.css │ │ └── css │ │ │ └── app.css │ ├── Todo.Web.Client.csproj │ ├── _Imports.razor │ ├── Program.cs │ ├── TodoApp.razor │ ├── Properties │ │ └── launchSettings.json │ ├── Components │ │ ├── TodoList.razor │ │ └── LogInForm.razor │ └── TodoClient.cs ├── Shared │ ├── Todo.Web.Shared.csproj │ └── SharedClass.cs └── Server │ ├── Authentication │ ├── TokenNames.cs │ ├── AuthenticationSchemes.cs │ ├── HttpAuthenticationStateProvider.cs │ ├── ExternalProviders.cs │ └── AuthenticationExtensions.cs │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── _Imports.razor │ ├── TodoApi.cs │ ├── Todo.Web.Server.csproj │ ├── AuthClient.cs │ ├── App.razor │ ├── Properties │ └── launchSettings.json │ ├── Program.cs │ └── AuthApi.cs ├── .vscode └── extensions.json ├── global.json ├── .devcontainer ├── init.sh └── devcontainer.json ├── TodoApp.AppHost ├── appsettings.Development.json ├── appsettings.json ├── TodoApp.AppHost.csproj ├── Program.cs ├── Properties │ └── launchSettings.json └── Extensions.cs ├── Todo.Api ├── appsettings.json ├── appsettings.Development.json ├── Authorization │ ├── CurrentUser.cs │ ├── CurrentUserExtensions.cs │ └── CheckCurrentUserAuthHandler.cs ├── TodoDbContext.cs ├── Users │ ├── TodoUser.cs │ └── UsersApi.cs ├── Migrations │ ├── 20221123165051_RemoveIsAdmin.cs │ ├── 20230714032431_ForeignKeyChange.cs │ ├── TodoDbContextModelSnapshot.cs │ ├── 20230714032431_ForeignKeyChange.Designer.cs │ ├── 20221123165051_RemoveIsAdmin.Designer.cs │ ├── 20221123071234_Initial.Designer.cs │ └── 20221123071234_Initial.cs ├── Todos │ ├── Todo.cs │ └── TodoApi.cs ├── Properties │ └── launchSettings.json ├── Todo.Api.csproj ├── Extensions │ ├── OpenApiOptionsExtensions.cs │ └── RateLimitExtensions.cs ├── Program.cs └── Filters │ └── ValidationFilter.cs ├── Directory.Build.props ├── Todo.Api.Tests ├── GlobalUsings.cs ├── Todo.Api.Tests.csproj ├── DbContextExtensions.cs ├── TokenService.cs ├── TodoApplication.cs ├── UserApiTests.cs └── TodoApiTests.cs ├── nuget.config ├── .github └── workflows │ └── ci.yaml ├── TodoApp.ServiceDefaults ├── TodoApp.ServiceDefaults.csproj └── Extensions.cs ├── Requests └── todo.http ├── LICENSE ├── Directory.Packages.props ├── README.md ├── TodoApp.sln └── .gitignore /.aspire/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "appHostPath": "../TodoApp.AppHost/TodoApp.AppHost.csproj" 3 | } -------------------------------------------------------------------------------- /Todo.Web/Client/wwwroot/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfowl/TodoApp/HEAD/Todo.Web/Client/wwwroot/bg.png -------------------------------------------------------------------------------- /Todo.Web/Client/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidfowl/TodoApp/HEAD/Todo.Web/Client/wwwroot/favicon.ico -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-dotnettools.csharp", 4 | "humao.rest-client" 5 | ] 6 | } -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.100", 4 | "rollForward": "latestFeature", 5 | "allowPrerelease": true 6 | } 7 | } -------------------------------------------------------------------------------- /.devcontainer/init.sh: -------------------------------------------------------------------------------- 1 | dotnet tool install dotnet-ef -g 2 | 3 | cat << \EOF >> ~/.bash_profile 4 | # Add .NET Core SDK tools 5 | export PATH="$PATH:/root/.dotnet/tools" 6 | EOF -------------------------------------------------------------------------------- /Todo.Web/Shared/Todo.Web.Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /Todo.Web/Server/Authentication/TokenNames.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Web.Server; 2 | 3 | public class TokenNames 4 | { 5 | public static readonly string AccessToken = "accessToken"; 6 | } 7 | -------------------------------------------------------------------------------- /TodoApp.AppHost/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Todo.Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /Todo.Web/Server/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Yarp": "Warning" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /TodoApp.AppHost/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Aspire.Hosting.Dcp": "Warning" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Todo.Web/Server/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Yarp": "Warning" 7 | } 8 | }, 9 | "AllowedHosts": "*" 10 | } 11 | -------------------------------------------------------------------------------- /Todo.Web/Server/Authentication/AuthenticationSchemes.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Web.Server; 2 | 3 | public class AuthenticationSchemes 4 | { 5 | // This is the scheme used to store login information from external providers 6 | public static string ExternalScheme => "External"; 7 | } 8 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/dotnet/sdk:8.0", 3 | "extensions": [ 4 | "ms-dotnettools.csharp" 5 | ], 6 | "postCreateCommand": "chmod +x .devcontainer/init.sh && .devcontainer/init.sh", 7 | "forwardPorts": [ 8 | 5000, 9 | 5001 10 | ] 11 | } -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | preview 8 | 9 | -------------------------------------------------------------------------------- /Todo.Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Information" 6 | } 7 | }, 8 | "RateLimiting": { 9 | "TokenLimit": 50, 10 | "TokensPerPeriod": 50, 11 | "QueueLimit": 50 12 | } 13 | } -------------------------------------------------------------------------------- /Todo.Web/Server/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Components.Authorization; 4 | @using Microsoft.AspNetCore.Components.Forms 5 | @using Microsoft.AspNetCore.Components.Routing 6 | @using Microsoft.AspNetCore.Components.Web 7 | @using Microsoft.AspNetCore.Components.Web.Virtualization 8 | @using Microsoft.JSInterop 9 | -------------------------------------------------------------------------------- /Todo.Web/Client/Todo.Web.Client.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Todo.Web/Client/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net 2 | @using System.Net.Http 3 | @using System.Net.Http.Json 4 | @using Microsoft.AspNetCore.Components.Routing 5 | @using Microsoft.AspNetCore.Components.Web 6 | @using Microsoft.AspNetCore.Components.WebAssembly.Http 7 | @using Microsoft.JSInterop 8 | @using Microsoft.AspNetCore.Components.Forms 9 | @using System.ComponentModel.DataAnnotations 10 | @using Todo.Web.Client.Components 11 | -------------------------------------------------------------------------------- /Todo.Web/Server/Authentication/HttpAuthenticationStateProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components.Authorization; 2 | 3 | internal class HttpAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor) : AuthenticationStateProvider 4 | { 5 | public override Task GetAuthenticationStateAsync() 6 | { 7 | return Task.FromResult(new AuthenticationState(httpContextAccessor.HttpContext?.User ?? new())); 8 | } 9 | } -------------------------------------------------------------------------------- /Todo.Api/Authorization/CurrentUser.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | 3 | namespace TodoApi; 4 | 5 | // A scoped service that exposes the current user information 6 | public class CurrentUser 7 | { 8 | public TodoUser? User { get; set; } 9 | public ClaimsPrincipal Principal { get; set; } = default!; 10 | 11 | public string Id => Principal.FindFirstValue(ClaimTypes.NameIdentifier)!; 12 | public bool IsAdmin => Principal.IsInRole("admin"); 13 | } -------------------------------------------------------------------------------- /Todo.Api.Tests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using System.Net.Http.Headers; 2 | global using Microsoft.AspNetCore.Identity; 3 | global using Microsoft.AspNetCore.Mvc; 4 | global using Microsoft.AspNetCore.Mvc.Testing; 5 | global using Microsoft.Data.Sqlite; 6 | global using Microsoft.EntityFrameworkCore; 7 | global using Microsoft.Extensions.DependencyInjection; 8 | global using Microsoft.Extensions.DependencyInjection.Extensions; 9 | global using Microsoft.Extensions.Hosting; 10 | 11 | -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - name: Setup .NET SDK 13 | uses: actions/setup-dotnet@v3 14 | 15 | - name: Restore dependencies 16 | run: dotnet restore TodoApp.sln 17 | 18 | - name: dotnet build 19 | run: dotnet build TodoApp.sln -c Release --no-restore 20 | 21 | - name: dotnet test 22 | run: dotnet test TodoApp.sln -c Release --no-build -------------------------------------------------------------------------------- /Todo.Web/Client/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components.WebAssembly.Hosting; 2 | using Todo.Web.Client; 3 | 4 | var builder = WebAssemblyHostBuilder.CreateDefault(args); 5 | 6 | builder.Services.AddHttpClient(client => 7 | { 8 | client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress); 9 | 10 | // The cookie auth stack detects this header and avoids redirects for unauthenticated 11 | // requests 12 | client.DefaultRequestHeaders.TryAddWithoutValidation("X-Requested-With", "XMLHttpRequest"); 13 | }); 14 | 15 | await builder.Build().RunAsync(); 16 | -------------------------------------------------------------------------------- /TodoApp.AppHost/TodoApp.AppHost.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Exe 7 | true 8 | 22f8485f-c345-41fa-9f21-ba96b1e511d1 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Todo.Api/TodoDbContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace TodoApi; 5 | 6 | public class TodoDbContext(DbContextOptions options) : IdentityDbContext(options) 7 | { 8 | public DbSet Todos => Set(); 9 | 10 | protected override void OnModelCreating(ModelBuilder builder) 11 | { 12 | builder.Entity() 13 | .HasOne() 14 | .WithMany() 15 | .HasForeignKey(t => t.OwnerId) 16 | .HasPrincipalKey(u => u.Id); 17 | 18 | base.OnModelCreating(builder); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Todo.Api/Users/TodoUser.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using Microsoft.AspNetCore.Identity; 3 | 4 | namespace TodoApi; 5 | 6 | // This is our TodoUser, we can modify this if we need 7 | // to add custom properties to the user 8 | public class TodoUser : IdentityUser { } 9 | 10 | // This is the DTO used to exchange username and password details to 11 | // the create user and token endpoints 12 | public class UserInfo 13 | { 14 | [Required] 15 | public string Email { get; set; } = default!; 16 | 17 | [Required] 18 | public string Password { get; set; } = default!; 19 | } 20 | 21 | public class ExternalUserInfo 22 | { 23 | [Required] 24 | public string Username { get; set; } = default!; 25 | 26 | [Required] 27 | public string ProviderKey { get; set; } = default!; 28 | } -------------------------------------------------------------------------------- /Todo.Api.Tests/Todo.Api.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /TodoApp.ServiceDefaults/TodoApp.ServiceDefaults.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /Todo.Web/Shared/SharedClass.cs: -------------------------------------------------------------------------------- 1 | /* Shared classes can be referenced by both the Client and Server */ 2 | using System.ComponentModel.DataAnnotations; 3 | using System.Text.Json.Serialization; 4 | 5 | public class TodoItem 6 | { 7 | public int Id { get; set; } 8 | [Required] 9 | public string Title { get; set; } = default!; 10 | public bool IsComplete { get; set; } 11 | } 12 | 13 | public class UserInfo 14 | { 15 | [Required] 16 | public string Email { get; set; } = default!; 17 | 18 | [Required] 19 | public string Password { get; set; } = default!; 20 | } 21 | 22 | public class ExternalUserInfo 23 | { 24 | [Required] 25 | public string Username { get; set; } = default!; 26 | 27 | [Required] 28 | public string ProviderKey { get; set; } = default!; 29 | } 30 | 31 | public record AuthToken([property: JsonPropertyName("accessToken")] string Token); -------------------------------------------------------------------------------- /Todo.Api/Migrations/20221123165051_RemoveIsAdmin.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace TodoApi.Migrations 6 | { 7 | /// 8 | public partial class RemoveIsAdmin : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.DropColumn( 14 | name: "IsAdmin", 15 | table: "AspNetUsers"); 16 | } 17 | 18 | /// 19 | protected override void Down(MigrationBuilder migrationBuilder) 20 | { 21 | migrationBuilder.AddColumn( 22 | name: "IsAdmin", 23 | table: "AspNetUsers", 24 | type: "INTEGER", 25 | nullable: false, 26 | defaultValue: false); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Todo.Api/Todos/Todo.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | public class Todo 4 | { 5 | public int Id { get; set; } 6 | [Required] 7 | public string Title { get; set; } = default!; 8 | public bool IsComplete { get; set; } 9 | 10 | [Required] 11 | public string OwnerId { get; set; } = default!; 12 | } 13 | 14 | // The DTO that excludes the OwnerId (we don't want that exposed to clients) 15 | public class TodoItem 16 | { 17 | public int Id { get; set; } 18 | [Required] 19 | public string Title { get; set; } = default!; 20 | public bool IsComplete { get; set; } 21 | } 22 | 23 | public static class TodoMappingExtensions 24 | { 25 | public static TodoItem AsTodoItem(this Todo todo) 26 | { 27 | return new() 28 | { 29 | Id = todo.Id, 30 | Title = todo.Title, 31 | IsComplete = todo.IsComplete, 32 | }; 33 | } 34 | } -------------------------------------------------------------------------------- /TodoApp.AppHost/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = DistributedApplication.CreateBuilder(args); 2 | 3 | var migrateOperation = builder.AddTodoDbMigration(); 4 | 5 | var todoapi = builder.AddProject("todoapi"); 6 | 7 | builder.AddProject("todo-web-server") 8 | .WithReference(todoapi); 9 | 10 | var dbDirectory = Path.Combine(todoapi.GetProjectDirectory(), ".db"); 11 | 12 | // Add sqlite-web to view the Todos.db database 13 | var sqliteWeb = builder.AddContainer("sqliteweb", "tomdesinto/sqliteweb") 14 | .WithHttpEndpoint(targetPort: 8080) 15 | .WithBindMount(dbDirectory, "/db") 16 | .WithArgs("Todos.db") 17 | .ExcludeFromManifest(); 18 | 19 | if (migrateOperation is not null) 20 | { 21 | // Wait for the migration to complete before running the api and ui 22 | todoapi.WaitForCompletion(migrateOperation); 23 | 24 | sqliteWeb.WaitForCompletion(migrateOperation); 25 | } 26 | 27 | 28 | builder.Build().Run(); 29 | -------------------------------------------------------------------------------- /Requests/todo.http: -------------------------------------------------------------------------------- 1 | @TodoApi_HostAddress = http://localhost:5000 2 | 3 | ### Create a user 4 | @password = 5 | 6 | POST {{TodoApi_HostAddress}}/users/register 7 | Content-Type: application/json 8 | 9 | { 10 | "email": "myuser@contoso.com", 11 | "password": "{{password}}" 12 | } 13 | 14 | ### Log in to get a token 15 | 16 | POST {{TodoApi_HostAddress}}/users/login 17 | Content-Type: application/json 18 | 19 | { 20 | "email": "myuser@contoso.com", 21 | "password": "{{password}}" 22 | } 23 | 24 | ### 25 | 26 | @token = 27 | 28 | ### Create a todo 29 | 30 | POST {{TodoApi_HostAddress}}/todos 31 | Authorization: Bearer {{token}} 32 | Content-Type: application/json 33 | 34 | { 35 | "title": "Get a haircut" 36 | } 37 | 38 | ### Get all todos 39 | 40 | GET {{TodoApi_HostAddress}}/todos 41 | Authorization: Bearer {{token}} 42 | 43 | ### Delete a todo 44 | DELETE {{TodoApi_HostAddress}}/todos/1 45 | Authorization: Bearer {{token}} -------------------------------------------------------------------------------- /Todo.Web/Client/TodoApp.razor: -------------------------------------------------------------------------------- 1 | @inject TodoClient Client 2 | 3 | @if (!string.IsNullOrEmpty(CurrentUser)) 4 | { 5 | 6 | 7 | Logged in as @CurrentUser 8 | Logout 9 | 10 | 11 | 12 | 13 | } 14 | else 15 | { 16 | 17 | } 18 | 19 | @code { 20 | [Parameter] 21 | public string? CurrentUser { get; set; } 22 | 23 | [Parameter] 24 | public string[] SocialProviders { get; set; } = Array.Empty(); 25 | 26 | 27 | void HandleLogin(string newUsername) 28 | { 29 | CurrentUser = newUsername; 30 | } 31 | 32 | async Task Logout() 33 | { 34 | if (await Client.LogoutAsync()) 35 | { 36 | CurrentUser = null; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Todo.Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:47743", 7 | "sslPort": 44371 8 | } 9 | }, 10 | "profiles": { 11 | "http": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "launchUrl": "scalar/v1", 16 | "hotReloadProfile": "aspnetcore", 17 | "applicationUrl": "http://localhost:5000", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "launchUrl": "scalar/v1", 27 | "hotReloadProfile": "aspnetcore", 28 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 29 | "environmentVariables": { 30 | "ASPNETCORE_ENVIRONMENT": "Development" 31 | } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /Todo.Web/Server/TodoApi.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Yarp.ReverseProxy.Forwarder; 3 | using Yarp.ReverseProxy.Transforms; 4 | 5 | namespace Todo.Web.Server; 6 | 7 | public static class TodoApi 8 | { 9 | public static RouteGroupBuilder MapTodos(this IEndpointRouteBuilder routes) 10 | { 11 | // The todo API translates the authentication cookie between the browser the BFF into an 12 | // access token that is sent to the todo API. We're using YARP to forward the request. 13 | 14 | var group = routes.MapGroup("/todos"); 15 | 16 | group.RequireAuthorization(); 17 | 18 | group.MapForwarder("{*path}", "http://todoapi", new ForwarderRequestConfig(), b => 19 | { 20 | b.AddRequestTransform(async c => 21 | { 22 | var accessToken = await c.HttpContext.GetTokenAsync(TokenNames.AccessToken); 23 | c.ProxyRequest.Headers.Authorization = new("Bearer", accessToken); 24 | }); 25 | }); 26 | 27 | return group; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Todo.Api.Tests/DbContextExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace TodoApi.Tests; 2 | 3 | internal static class DbContextExtensions 4 | { 5 | public static IServiceCollection AddDbContextOptions(this IServiceCollection services, Action> configure) where TContext : DbContext 6 | { 7 | // Remove the existing DbContextOptions 8 | // we want to override the settings and calling AddDbContext again 9 | // will noop. 10 | services.RemoveAll(typeof(DbContextOptions)); 11 | 12 | // Add the options as singletons since the IDbContextFactory as a singleton 13 | 14 | // Add the DbContextOptions 15 | var dbContextOptionsBuilder = new DbContextOptionsBuilder(); 16 | configure(dbContextOptionsBuilder); 17 | services.AddSingleton(dbContextOptionsBuilder.Options); 18 | 19 | // The untyped version just calls the typed one 20 | services.AddSingleton(s => s.GetRequiredService>()); 21 | 22 | return services; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Todo.Api/Todo.Api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | e616b306-8ad0-4843-a0e0-79b3b5655c22 5 | todo-api 6 | latest 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 David Fowler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /TodoApp.AppHost/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "https": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "applicationUrl": "https://localhost:17249;http://localhost:15169", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development", 11 | "DOTNET_ENVIRONMENT": "Development", 12 | "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21226", 13 | "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22195" 14 | } 15 | }, 16 | "http": { 17 | "commandName": "Project", 18 | "dotnetRunMessages": true, 19 | "launchBrowser": true, 20 | "applicationUrl": "http://localhost:15169", 21 | "environmentVariables": { 22 | "ASPNETCORE_ENVIRONMENT": "Development", 23 | "DOTNET_ENVIRONMENT": "Development", 24 | "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19224", 25 | "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20024" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Todo.Web/Server/Authentication/ExternalProviders.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Microsoft.AspNetCore.Authentication.Cookies; 3 | 4 | namespace Todo.Web.Server; 5 | 6 | public class ExternalProviders(IAuthenticationSchemeProvider schemeProvider) 7 | { 8 | private Task? _providerNames; 9 | 10 | public Task GetProviderNamesAsync() 11 | { 12 | return _providerNames ??= GetProviderNamesAsyncCore(); 13 | } 14 | 15 | private async Task GetProviderNamesAsyncCore() 16 | { 17 | List? providerNames = null; 18 | 19 | var schemes = await schemeProvider.GetAllSchemesAsync(); 20 | 21 | foreach (var s in schemes) 22 | { 23 | // We're assuming all schemes that aren't cookies are social 24 | if (s.Name == CookieAuthenticationDefaults.AuthenticationScheme || 25 | s.Name == AuthenticationSchemes.ExternalScheme) 26 | { 27 | continue; 28 | } 29 | 30 | providerNames ??= []; 31 | providerNames.Add(s.Name); 32 | } 33 | 34 | return providerNames?.ToArray() ?? []; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Todo.Web/Client/wwwroot/bootstrap/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011-2021 Twitter, Inc. 4 | Copyright (c) 2011-2021 The Bootstrap Authors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Todo.Web/Server/Todo.Web.Server.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | todo-web-server 5 | latest 6 | c3677e8f-17d8-492d-8844-36ade85166e5 7 | 8.0 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Todo.Web/Server/AuthClient.cs: -------------------------------------------------------------------------------- 1 | namespace Todo.Web.Server; 2 | 3 | public class AuthClient(HttpClient client) 4 | { 5 | public async Task GetTokenAsync(UserInfo userInfo) 6 | { 7 | var response = await client.PostAsJsonAsync("users/login", userInfo); 8 | 9 | if (!response.IsSuccessStatusCode) 10 | { 11 | return null; 12 | } 13 | 14 | var token = await response.Content.ReadFromJsonAsync(); 15 | 16 | return token?.Token; 17 | } 18 | 19 | public async Task CreateUserAsync(UserInfo userInfo) 20 | { 21 | var response = await client.PostAsJsonAsync("users/register", userInfo); 22 | 23 | if (!response.IsSuccessStatusCode) 24 | { 25 | return null; 26 | } 27 | 28 | return await GetTokenAsync(userInfo); 29 | } 30 | 31 | public async Task GetOrCreateUserAsync(string provider, ExternalUserInfo userInfo) 32 | { 33 | var response = await client.PostAsJsonAsync($"users/token/{provider}", userInfo); 34 | 35 | if (!response.IsSuccessStatusCode) 36 | { 37 | return null; 38 | } 39 | 40 | var token = await response.Content.ReadFromJsonAsync(); 41 | 42 | return token?.Token; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Todo.Web/Client/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "iisExpress": { 4 | "applicationUrl": "http://localhost:39359", 5 | "sslPort": 44344 6 | } 7 | }, 8 | "profiles": { 9 | "http": { 10 | "commandName": "Project", 11 | "dotnetRunMessages": true, 12 | "launchBrowser": true, 13 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 14 | "applicationUrl": "http://localhost:5147", 15 | "environmentVariables": { 16 | "ASPNETCORE_ENVIRONMENT": "Development" 17 | } 18 | }, 19 | "https": { 20 | "commandName": "Project", 21 | "dotnetRunMessages": true, 22 | "launchBrowser": true, 23 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 24 | "applicationUrl": "https://localhost:7123;http://localhost:5147", 25 | "environmentVariables": { 26 | "ASPNETCORE_ENVIRONMENT": "Development" 27 | } 28 | }, 29 | "IIS Express": { 30 | "commandName": "IISExpress", 31 | "launchBrowser": true, 32 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 33 | "environmentVariables": { 34 | "ASPNETCORE_ENVIRONMENT": "Development" 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Todo.Api/Authorization/CurrentUserExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using Microsoft.AspNetCore.Authentication; 3 | using Microsoft.AspNetCore.Identity; 4 | 5 | namespace TodoApi; 6 | 7 | public static class CurrentUserExtensions 8 | { 9 | public static IServiceCollection AddCurrentUser(this IServiceCollection services) 10 | { 11 | services.AddScoped(); 12 | services.AddScoped(); 13 | return services; 14 | } 15 | 16 | private sealed class ClaimsTransformation(CurrentUser currentUser, UserManager userManager) : IClaimsTransformation 17 | { 18 | public async Task TransformAsync(ClaimsPrincipal principal) 19 | { 20 | // We're not going to transform anything. We're using this as a hook into authorization 21 | // to set the current user without adding custom middleware. 22 | currentUser.Principal = principal; 23 | 24 | if (principal.FindFirstValue(ClaimTypes.NameIdentifier) is { Length: > 0 } id) 25 | { 26 | // Resolve the user manager and see if the current user is a valid user in the database 27 | // we do this once and store it on the current user. 28 | currentUser.User = await userManager.FindByIdAsync(id); 29 | } 30 | 31 | return principal; 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /Todo.Web/Server/App.razor: -------------------------------------------------------------------------------- 1 | @using Todo.Web.Client; 2 | @inject ExternalProviders providers 3 | @inject AuthenticationStateProvider authStateProvider 4 | 5 | @page "/" 6 | 7 | 8 | 9 | 10 | 11 | 12 | Todo App 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | An unhandled error has occurred. 27 | Reload 28 | 🗙 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | @code { 37 | string[] providerNames = Array.Empty(); 38 | string? userName; 39 | 40 | protected override async Task OnInitializedAsync() 41 | { 42 | var authState = await authStateProvider.GetAuthenticationStateAsync(); 43 | userName = authState?.User.Identity?.Name; 44 | providerNames = await providers.GetProviderNamesAsync() ?? Array.Empty(); 45 | } 46 | } -------------------------------------------------------------------------------- /Todo.Api/Authorization/CheckCurrentUserAuthHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | 3 | namespace TodoApi; 4 | 5 | public static class AuthorizationHandlerExtensions 6 | { 7 | public static AuthorizationBuilder AddCurrentUserHandler(this AuthorizationBuilder builder) 8 | { 9 | builder.Services.AddScoped(); 10 | return builder; 11 | } 12 | 13 | // Adds the current user requirement that will activate our authorization handler 14 | public static AuthorizationPolicyBuilder RequireCurrentUser(this AuthorizationPolicyBuilder builder) 15 | { 16 | return builder.RequireAuthenticatedUser() 17 | .AddRequirements(new CheckCurrentUserRequirement()); 18 | } 19 | 20 | private class CheckCurrentUserRequirement : IAuthorizationRequirement { } 21 | 22 | // This authorization handler verifies that the user exists even if there's 23 | // a valid token 24 | private class CheckCurrentUserAuthHandler(CurrentUser currentUser) : AuthorizationHandler 25 | { 26 | protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, CheckCurrentUserRequirement requirement) 27 | { 28 | // TODO: Check user if the user is locked out as well 29 | if (currentUser.User is not null) 30 | { 31 | context.Succeed(requirement); 32 | } 33 | 34 | return Task.CompletedTask; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Todo.Api.Tests/TokenService.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using Microsoft.AspNetCore.Authentication; 3 | using Microsoft.AspNetCore.Authentication.BearerToken; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace TodoApi; 7 | 8 | public sealed class TokenService(SignInManager signInManager, IOptionsMonitor options) 9 | { 10 | private readonly BearerTokenOptions _options = options.Get(IdentityConstants.BearerScheme); 11 | 12 | public async Task GenerateTokenAsync(string username, bool isAdmin = false) 13 | { 14 | var claimsPrincipal = await signInManager.CreateUserPrincipalAsync(new TodoUser { Id = username, UserName = username }); 15 | 16 | if (isAdmin) 17 | { 18 | ((ClaimsIdentity?)claimsPrincipal.Identity)?.AddClaim(new(ClaimTypes.Role, "admin")); 19 | } 20 | 21 | // This is copied from https://github.com/dotnet/aspnetcore/blob/238dabc8bf7a6d9485d420db01d7942044b218ee/src/Security/Authentication/BearerToken/src/BearerTokenHandler.cs#L66 22 | var timeProvider = _options.TimeProvider ?? TimeProvider.System; 23 | 24 | var utcNow = timeProvider.GetUtcNow(); 25 | 26 | var properties = new AuthenticationProperties 27 | { 28 | ExpiresUtc = utcNow + _options.BearerTokenExpiration 29 | }; 30 | 31 | var ticket = new AuthenticationTicket( 32 | claimsPrincipal, properties, $"{IdentityConstants.BearerScheme}:AccessToken"); 33 | 34 | return _options.BearerTokenProtector.Protect(ticket); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Todo.Web/Server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "http": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | }, 9 | "dotnetRunMessages": true, 10 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 11 | "applicationUrl": "http://localhost:5147" 12 | }, 13 | "https": { 14 | "commandName": "Project", 15 | "launchBrowser": true, 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | }, 19 | "dotnetRunMessages": true, 20 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 21 | "applicationUrl": "https://localhost:7123;http://localhost:5147" 22 | }, 23 | "IIS Express": { 24 | "commandName": "IISExpress", 25 | "launchBrowser": true, 26 | "environmentVariables": { 27 | "ASPNETCORE_ENVIRONMENT": "Development" 28 | }, 29 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" 30 | } 31 | }, 32 | "iisExpress": { 33 | "applicationUrl": "http://localhost:39359", 34 | "sslPort": 44344 35 | }, 36 | "iisSettings": { 37 | "windowsAuthentication": false, 38 | "anonymousAuthentication": true, 39 | "iisExpress": { 40 | "applicationUrl": "http://localhost:51195/", 41 | "sslPort": 44399 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /Todo.Api/Extensions/OpenApiOptionsExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.AspNetCore.Identity; 3 | using Microsoft.AspNetCore.OpenApi; 4 | using Microsoft.OpenApi.Models; 5 | 6 | public static class OpenApiOptionsExtensions 7 | { 8 | public static OpenApiOptions AddBearerTokenAuthentication(this OpenApiOptions options) 9 | { 10 | var scheme = new OpenApiSecurityScheme() 11 | { 12 | Type = SecuritySchemeType.Http, 13 | Name = IdentityConstants.BearerScheme, 14 | Scheme = "Bearer" 15 | }; 16 | 17 | var reference = new OpenApiSecurityScheme() 18 | { 19 | Reference = new OpenApiReference 20 | { 21 | Type = ReferenceType.SecurityScheme, 22 | Id = IdentityConstants.BearerScheme 23 | } 24 | }; 25 | 26 | options.AddDocumentTransformer((document, context, cancellationToken) => 27 | { 28 | document.Components ??= new OpenApiComponents(); 29 | document.Components.SecuritySchemes.Add(IdentityConstants.BearerScheme, scheme); 30 | return Task.CompletedTask; 31 | }); 32 | 33 | options.AddOperationTransformer((operation, context, cancellationToken) => 34 | { 35 | if (context.Description.ActionDescriptor.EndpointMetadata.OfType().Any()) 36 | { 37 | operation.Security = [new OpenApiSecurityRequirement { [reference] = [] }]; 38 | } 39 | return Task.CompletedTask; 40 | }); 41 | 42 | return options; 43 | } 44 | } -------------------------------------------------------------------------------- /Todo.Web/Server/Program.cs: -------------------------------------------------------------------------------- 1 | using Todo.Web.Client; 2 | using Todo.Web.Server; 3 | 4 | var builder = WebApplication.CreateBuilder(args); 5 | 6 | builder.AddServiceDefaults(); 7 | 8 | // Configure auth with the front end 9 | builder.AddAuthentication(); 10 | builder.Services.AddAuthorizationBuilder(); 11 | 12 | // Configure data protection, setup the application discriminator so that the data protection keys can be shared between the BFF and this API 13 | builder.Services.AddDataProtection(o => o.ApplicationDiscriminator = "TodoApp"); 14 | 15 | // Must add client services 16 | builder.Services.AddScoped(); 17 | 18 | builder.Services.AddRazorComponents() 19 | .AddInteractiveWebAssemblyComponents(); 20 | 21 | // Add the forwarder to make sending requests to the backend easier 22 | builder.Services.AddHttpForwarderWithServiceDiscovery(); 23 | 24 | // Configure the HttpClient for the backend API 25 | builder.Services.AddHttpClient(client => 26 | { 27 | client.BaseAddress = new("http://todoapi"); 28 | }); 29 | 30 | var app = builder.Build(); 31 | 32 | app.MapDefaultEndpoints(); 33 | 34 | // Configure the HTTP request pipeline. 35 | if (app.Environment.IsDevelopment()) 36 | { 37 | app.UseWebAssemblyDebugging(); 38 | } 39 | else 40 | { 41 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 42 | app.UseHsts(); 43 | } 44 | 45 | app.UseHttpsRedirection(); 46 | app.UseAntiforgery(); 47 | 48 | app.MapStaticAssets(); 49 | app.MapRazorComponents() 50 | .AddInteractiveWebAssemblyRenderMode(); 51 | 52 | // Configure the APIs 53 | app.MapAuth(); 54 | app.MapTodos(); 55 | 56 | app.Run(); 57 | 58 | -------------------------------------------------------------------------------- /TodoApp.AppHost/Extensions.cs: -------------------------------------------------------------------------------- 1 | internal static class Extensions 2 | { 3 | public static IResourceBuilder? AddTodoDbMigration(this IDistributedApplicationBuilder builder) 4 | { 5 | IResourceBuilder? migrateOperation = default; 6 | 7 | if (builder.ExecutionContext.IsRunMode) 8 | { 9 | var projectDirectory = Path.GetDirectoryName(new Projects.Todo_Api().ProjectPath)!; 10 | var dbDirectory = Path.Combine(projectDirectory, ".db"); 11 | 12 | if (!Directory.Exists(dbDirectory)) 13 | { 14 | Directory.CreateDirectory(dbDirectory); 15 | 16 | migrateOperation = builder.AddEfMigration("todo-db-migration"); 17 | } 18 | } 19 | 20 | return migrateOperation; 21 | } 22 | 23 | public static IResourceBuilder AddEfMigration(this IDistributedApplicationBuilder builder, string name) 24 | where TProject : IProjectMetadata, new() 25 | { 26 | var projectDirectory = Path.GetDirectoryName(new TProject().ProjectPath)!; 27 | 28 | // Install the EF tool 29 | var install = builder.AddExecutable("install-ef", "dotnet", projectDirectory, "tool", "install", "--global", "dotnet-ef"); 30 | 31 | // TODO: Support passing a connection string 32 | return builder.AddExecutable(name, "dotnet", projectDirectory, "ef", "database", "update", "--no-build") 33 | .WaitForCompletion(install); 34 | } 35 | 36 | public static string GetProjectDirectory(this IResourceBuilder project) => 37 | Path.GetDirectoryName(project.Resource.GetProjectMetadata().ProjectPath)!; 38 | } 39 | -------------------------------------------------------------------------------- /Todo.Api/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.HttpLogging; 2 | using Microsoft.AspNetCore.Identity; 3 | using Scalar.AspNetCore; 4 | 5 | var builder = WebApplication.CreateBuilder(args); 6 | 7 | builder.AddServiceDefaults(); 8 | 9 | // Configure data protection, setup the application discriminator 10 | // so that the data protection keys can be shared between the BFF and this API 11 | builder.Services.AddDataProtection(o => o.ApplicationDiscriminator = "TodoApp"); 12 | 13 | // Configure auth 14 | builder.Services.AddAuthentication().AddBearerToken(IdentityConstants.BearerScheme); 15 | builder.Services.AddAuthorizationBuilder().AddCurrentUserHandler(); 16 | 17 | // Configure identity 18 | builder.Services.AddIdentityCore() 19 | .AddEntityFrameworkStores() 20 | .AddApiEndpoints(); 21 | 22 | // Configure the database 23 | var connectionString = builder.Configuration.GetConnectionString("Todos") ?? "Data Source=.db/Todos.db"; 24 | builder.Services.AddSqlite(connectionString); 25 | 26 | // State that represents the current user from the database *and* the request 27 | builder.Services.AddCurrentUser(); 28 | 29 | // Configure Open API 30 | builder.Services.AddOpenApi(options => options.AddBearerTokenAuthentication()); 31 | 32 | // Configure rate limiting 33 | builder.Services.AddRateLimiting(); 34 | 35 | builder.Services.AddHttpLogging(o => 36 | { 37 | if (builder.Environment.IsDevelopment()) 38 | { 39 | o.CombineLogs = true; 40 | o.LoggingFields = HttpLoggingFields.ResponseBody | HttpLoggingFields.ResponseHeaders; 41 | } 42 | }); 43 | 44 | var app = builder.Build(); 45 | 46 | app.UseHttpLogging(); 47 | app.UseRateLimiter(); 48 | 49 | if (app.Environment.IsDevelopment()) 50 | { 51 | app.MapScalarApiReference(options => 52 | { 53 | options.Servers = []; 54 | options.Authentication = new() { PreferredSecuritySchemes = [IdentityConstants.BearerScheme] }; 55 | }); 56 | } 57 | 58 | app.MapOpenApi(); 59 | 60 | app.MapDefaultEndpoints(); 61 | 62 | app.Map("/", () => Results.Redirect("/scalar/v1")); 63 | 64 | // Configure the APIs 65 | app.MapTodos(); 66 | app.MapUsers(); 67 | 68 | app.Run(); 69 | -------------------------------------------------------------------------------- /Todo.Api/Filters/ValidationFilter.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using MiniValidation; 3 | 4 | namespace TodoApi; 5 | 6 | public static class ValidationFilterExtensions 7 | { 8 | private static readonly ProducesResponseTypeMetadata ValidationErrorResponseMetadata = 9 | new(400, typeof(HttpValidationProblemDetails), ["application/problem+json"]); 10 | 11 | public static TBuilder WithParameterValidation(this TBuilder builder, params Type[] typesToValidate) where TBuilder : IEndpointConventionBuilder 12 | { 13 | builder.Add(eb => 14 | { 15 | var methodInfo = eb.Metadata.OfType().FirstOrDefault(); 16 | 17 | if (methodInfo is null) 18 | { 19 | return; 20 | } 21 | 22 | // Track the indices of validatable parameters 23 | List? parameterIndexesToValidate = null; 24 | foreach (var p in methodInfo.GetParameters()) 25 | { 26 | if (typesToValidate.Contains(p.ParameterType)) 27 | { 28 | parameterIndexesToValidate ??= []; 29 | parameterIndexesToValidate.Add(p.Position); 30 | } 31 | } 32 | 33 | if (parameterIndexesToValidate is null) 34 | { 35 | // Nothing to validate so don't add the filter to this endpoint 36 | return; 37 | } 38 | 39 | // We can respond with problem details if there's a validation error 40 | eb.Metadata.Add(ValidationErrorResponseMetadata); 41 | 42 | eb.FilterFactories.Add((context, next) => 43 | { 44 | return efic => 45 | { 46 | foreach (var index in parameterIndexesToValidate) 47 | { 48 | if (efic.Arguments[index] is { } arg && !MiniValidator.TryValidate(arg, out var errors)) 49 | { 50 | return new ValueTask(Results.ValidationProblem(errors)); 51 | } 52 | } 53 | 54 | return next(efic); 55 | }; 56 | }); 57 | }); 58 | 59 | return builder; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Todo.Api/Extensions/RateLimitExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using System.Threading.RateLimiting; 3 | using Microsoft.AspNetCore.RateLimiting; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace TodoApi; 7 | 8 | public static class RateLimitExtensions 9 | { 10 | private static readonly string Policy = "PerUserRatelimit"; 11 | 12 | public static IServiceCollection AddRateLimiting(this IServiceCollection services) 13 | { 14 | services.AddRateLimiter(); 15 | 16 | // Setup defaults for the TokenBucketRateLimiterOptions and read them from config if defined 17 | // In theory this could be per user using named options 18 | services.AddOptions() 19 | .Configure(options => 20 | { 21 | // Set defaults 22 | options.ReplenishmentPeriod = TimeSpan.FromSeconds(10); 23 | options.AutoReplenishment = true; 24 | options.TokenLimit = 100; 25 | options.TokensPerPeriod = 100; 26 | options.QueueLimit = 100; 27 | }) 28 | .BindConfiguration("RateLimiting"); 29 | 30 | // Setup the rate limiting policies taking the per user rate limiting options into account 31 | services.AddOptions() 32 | .Configure((RateLimiterOptions options, IOptionsMonitor perUserRateLimitingOptions) => 33 | { 34 | options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; 35 | 36 | options.AddPolicy(Policy, context => 37 | { 38 | // We always have a user name 39 | var username = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!; 40 | 41 | return RateLimitPartition.GetTokenBucketLimiter(username, key => 42 | { 43 | return perUserRateLimitingOptions.CurrentValue; 44 | }); 45 | }); 46 | }); 47 | 48 | return services; 49 | } 50 | 51 | public static IEndpointConventionBuilder RequirePerUserRateLimit(this IEndpointConventionBuilder builder) 52 | { 53 | return builder.RequireRateLimiting(Policy); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Todo.Api/Users/UsersApi.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication.BearerToken; 2 | using Microsoft.AspNetCore.DataProtection; 3 | using Microsoft.AspNetCore.Http.HttpResults; 4 | using Microsoft.AspNetCore.Identity; 5 | 6 | namespace TodoApi; 7 | 8 | public static class UsersApi 9 | { 10 | public static RouteGroupBuilder MapUsers(this IEndpointRouteBuilder routes) 11 | { 12 | var group = routes.MapGroup("/users"); 13 | 14 | group.WithTags("Users"); 15 | 16 | // TODO: Add service to service auth between the BFF and this API 17 | 18 | group.WithParameterValidation(typeof(ExternalUserInfo)); 19 | 20 | group.MapIdentityApi(); 21 | 22 | // The MapIdentityApi doesn't expose an external login endpoint so we write this custom endpoint that follows 23 | // a similar pattern 24 | group.MapPost("/token/{provider}", async Task, SignInHttpResult, ValidationProblem>> (string provider, ExternalUserInfo userInfo, UserManager userManager, SignInManager signInManager, IDataProtectionProvider dataProtectionProvider) => 25 | { 26 | var protector = dataProtectionProvider.CreateProtector(provider); 27 | 28 | var providerKey = protector.Unprotect(userInfo.ProviderKey); 29 | 30 | var user = await userManager.FindByLoginAsync(provider, providerKey); 31 | 32 | var result = IdentityResult.Success; 33 | 34 | if (user is null) 35 | { 36 | user = new TodoUser() { UserName = userInfo.Username }; 37 | 38 | result = await userManager.CreateAsync(user); 39 | 40 | if (result.Succeeded) 41 | { 42 | result = await userManager.AddLoginAsync(user, new UserLoginInfo(provider, providerKey, displayName: null)); 43 | } 44 | } 45 | 46 | if (result.Succeeded) 47 | { 48 | var principal = await signInManager.CreateUserPrincipalAsync(user); 49 | 50 | return TypedResults.SignIn(principal); 51 | } 52 | 53 | return TypedResults.ValidationProblem(result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description })); 54 | }); 55 | 56 | return group; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Todo.Api/Migrations/20230714032431_ForeignKeyChange.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace TodoApi.Migrations 6 | { 7 | /// 8 | public partial class ForeignKeyChange : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.DropForeignKey( 14 | name: "FK_Todos_AspNetUsers_OwnerId", 15 | table: "Todos"); 16 | 17 | migrationBuilder.DropUniqueConstraint( 18 | name: "AK_AspNetUsers_UserName", 19 | table: "AspNetUsers"); 20 | 21 | migrationBuilder.AlterColumn( 22 | name: "UserName", 23 | table: "AspNetUsers", 24 | type: "TEXT", 25 | maxLength: 256, 26 | nullable: true, 27 | oldClrType: typeof(string), 28 | oldType: "TEXT", 29 | oldMaxLength: 256); 30 | 31 | migrationBuilder.AddForeignKey( 32 | name: "FK_Todos_AspNetUsers_OwnerId", 33 | table: "Todos", 34 | column: "OwnerId", 35 | principalTable: "AspNetUsers", 36 | principalColumn: "Id", 37 | onDelete: ReferentialAction.Cascade); 38 | } 39 | 40 | /// 41 | protected override void Down(MigrationBuilder migrationBuilder) 42 | { 43 | migrationBuilder.DropForeignKey( 44 | name: "FK_Todos_AspNetUsers_OwnerId", 45 | table: "Todos"); 46 | 47 | migrationBuilder.AlterColumn( 48 | name: "UserName", 49 | table: "AspNetUsers", 50 | type: "TEXT", 51 | maxLength: 256, 52 | nullable: false, 53 | defaultValue: "", 54 | oldClrType: typeof(string), 55 | oldType: "TEXT", 56 | oldMaxLength: 256, 57 | oldNullable: true); 58 | 59 | migrationBuilder.AddUniqueConstraint( 60 | name: "AK_AspNetUsers_UserName", 61 | table: "AspNetUsers", 62 | column: "UserName"); 63 | 64 | migrationBuilder.AddForeignKey( 65 | name: "FK_Todos_AspNetUsers_OwnerId", 66 | table: "Todos", 67 | column: "OwnerId", 68 | principalTable: "AspNetUsers", 69 | principalColumn: "UserName", 70 | onDelete: ReferentialAction.Cascade); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Todo.Web/Client/Components/TodoList.razor: -------------------------------------------------------------------------------- 1 | @inject TodoClient Client 2 | 3 | @if (todos is null) 4 | { 5 | 6 | 7 | 8 | } 9 | else 10 | { 11 | Todo List 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | @foreach (var todo in todos) 23 | { 24 | 25 | 26 | 27 | @todo.Title 28 | 29 | DeleteTodo(todo))">🗙 30 | 31 | } 32 | 33 | } 34 | 35 | @code { 36 | List? todos; 37 | EditForm? form; 38 | 39 | [Required] 40 | public string? NewTodo { get; set; } 41 | 42 | [Parameter] 43 | public EventCallback OnForbidden { get; set; } 44 | 45 | protected override async Task OnInitializedAsync() 46 | { 47 | await LoadTodos(); 48 | } 49 | 50 | async Task LoadTodos() 51 | { 52 | (var statusCode, todos) = await Client.GetTodosAsync(); 53 | 54 | if (statusCode == HttpStatusCode.Forbidden || statusCode == HttpStatusCode.Unauthorized) 55 | { 56 | await OnForbidden.InvokeAsync(); 57 | } 58 | } 59 | 60 | async Task AddTodo() 61 | { 62 | var createdTodo = await Client.AddTodoAsync(NewTodo); 63 | if (createdTodo is not null) 64 | { 65 | NewTodo = null; 66 | form!.EditContext!.MarkAsUnmodified(); 67 | todos!.Add(createdTodo); 68 | } 69 | } 70 | 71 | async Task DeleteTodo(TodoItem todo) 72 | { 73 | if (await Client.DeleteTodoAsync(todo.Id)) 74 | { 75 | todos!.Remove(todo); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Todo.Web/Client/TodoClient.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | 4 | namespace Todo.Web.Client; 5 | 6 | public class TodoClient(HttpClient client) 7 | { 8 | public async Task AddTodoAsync(string? title) 9 | { 10 | if (string.IsNullOrEmpty(title)) 11 | { 12 | return null; 13 | } 14 | 15 | TodoItem? createdTodo = null; 16 | 17 | var response = await client.PostAsJsonAsync("todos", new TodoItem { Title = title }); 18 | 19 | if (response.IsSuccessStatusCode) 20 | { 21 | createdTodo = await response.Content.ReadFromJsonAsync(); 22 | } 23 | 24 | return createdTodo; 25 | } 26 | 27 | public async Task DeleteTodoAsync(int id) 28 | { 29 | var response = await client.DeleteAsync($"todos/{id}"); 30 | return response.IsSuccessStatusCode; 31 | } 32 | 33 | public async Task<(HttpStatusCode, List?)> GetTodosAsync() 34 | { 35 | // This is a hack from hell to avoid having to know if this is running server or client side 36 | if (client.BaseAddress is null) 37 | { 38 | return (HttpStatusCode.OK, new()); 39 | } 40 | 41 | var response = await client.GetAsync("todos"); 42 | var statusCode = response.StatusCode; 43 | List? todos = null; 44 | 45 | if (response.IsSuccessStatusCode) 46 | { 47 | todos = await response.Content.ReadFromJsonAsync>(); 48 | } 49 | 50 | return (statusCode, todos); 51 | } 52 | 53 | public async Task LoginAsync(string? email, string? password) 54 | { 55 | if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password)) 56 | { 57 | return false; 58 | } 59 | 60 | var response = await client.PostAsJsonAsync("auth/login", new UserInfo { Email = email, Password = password }); 61 | return response.IsSuccessStatusCode; 62 | } 63 | 64 | public async Task CreateUserAsync(string? email, string? password) 65 | { 66 | if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password)) 67 | { 68 | return false; 69 | } 70 | 71 | var response = await client.PostAsJsonAsync("auth/register", new UserInfo { Email = email, Password = password }); 72 | return response.IsSuccessStatusCode; 73 | } 74 | 75 | public async Task LogoutAsync() 76 | { 77 | var response = await client.PostAsync("auth/logout", content: null); 78 | return response.IsSuccessStatusCode; 79 | } 80 | } -------------------------------------------------------------------------------- /Todo.Web/Client/Components/LogInForm.razor: -------------------------------------------------------------------------------- 1 | @inject TodoClient Client 2 | 3 | 4 | 5 | 6 | Email 7 | 8 | 9 | 10 | 11 | Password 12 | 13 | 14 | 15 | 16 | Login 17 | Create User 18 | 19 | 20 | 21 | 22 | @foreach (var provider in SocialProviders) 23 | { 24 | @provider 25 | } 26 | 27 | 28 | @if (!string.IsNullOrEmpty(alertMessage)) 29 | { 30 | @alertMessage 31 | } 32 | 33 | @code { 34 | string? alertMessage; 35 | 36 | [Required] 37 | [StringLength(256)] 38 | public string? Email { get; set; } 39 | 40 | [Required] 41 | [StringLength(32, MinimumLength = 6, ErrorMessage = "The password must be between 6 and 32 characters long.")] 42 | [RegularExpression("^(?=.*\\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z])(?=.*[^a-zA-Z\\d]).*$", 43 | MatchTimeoutInMilliseconds = 1000, 44 | ErrorMessage = "The password must contain a lower-case letter, an upper-case letter, a digit and a special character.")] 45 | public string? Password { get; set; } 46 | 47 | [Parameter] 48 | public EventCallback OnLoggedIn { get; set; } 49 | 50 | [Parameter] 51 | public string[] SocialProviders { get; set; } = Array.Empty(); 52 | 53 | async Task Login() 54 | { 55 | alertMessage = null; 56 | if (await Client.LoginAsync(Email, Password)) 57 | { 58 | await OnLoggedIn.InvokeAsync(Email); 59 | } 60 | else 61 | { 62 | alertMessage = "Login failed"; 63 | } 64 | } 65 | 66 | async Task Create() 67 | { 68 | alertMessage = null; 69 | if (await Client.CreateUserAsync(Email, Password)) 70 | { 71 | await OnLoggedIn.InvokeAsync(Email); 72 | } 73 | else 74 | { 75 | alertMessage = "Failed to create user"; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /Todo.Web/Client/wwwroot/css/app.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 14px; 3 | } 4 | 5 | @media (min-width: 768px) { 6 | html { 7 | font-size: 16px; 8 | } 9 | } 10 | 11 | .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { 12 | box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; 13 | } 14 | 15 | html { 16 | position: relative; 17 | min-height: 100%; 18 | } 19 | 20 | body { 21 | margin-bottom: 60px; 22 | } 23 | .content { 24 | padding-top: 1.1rem; 25 | } 26 | 27 | .valid.modified:not([type=checkbox]) { 28 | outline: 1px solid #26b050; 29 | } 30 | 31 | .invalid { 32 | outline: 1px solid red; 33 | } 34 | 35 | .validation-message { 36 | color: red; 37 | } 38 | 39 | .form-horizontal { 40 | display: block; 41 | width: 40%; 42 | margin: 0 auto; 43 | } 44 | 45 | #blazor-error-ui { 46 | background: lightyellow; 47 | bottom: 0; 48 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 49 | display: none; 50 | left: 0; 51 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 52 | position: fixed; 53 | width: 100%; 54 | z-index: 1000; 55 | } 56 | 57 | #blazor-error-ui .dismiss { 58 | cursor: pointer; 59 | position: absolute; 60 | right: 0.75rem; 61 | top: 0.5rem; 62 | } 63 | 64 | .blazor-error-boundary { 65 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 66 | padding: 1rem 1rem 1rem 3.7rem; 67 | color: white; 68 | } 69 | 70 | .blazor-error-boundary::after { 71 | content: "An error has occurred." 72 | } 73 | 74 | -------------------------------------------------------------------------------- /Todo.Api/Todos/TodoApi.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http.HttpResults; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace TodoApi; 5 | 6 | internal static class TodoApi 7 | { 8 | public static RouteGroupBuilder MapTodos(this IEndpointRouteBuilder routes) 9 | { 10 | var group = routes.MapGroup("/todos"); 11 | 12 | group.WithTags("Todos"); 13 | 14 | // Add security requirements, all incoming requests to this API *must* 15 | // be authenticated with a valid user. 16 | group.RequireAuthorization(pb => pb.RequireCurrentUser()); 17 | 18 | // Rate limit all of the APIs 19 | group.RequirePerUserRateLimit(); 20 | 21 | // Validate the parameters 22 | group.WithParameterValidation(typeof(TodoItem)); 23 | 24 | group.MapGet("/", async (TodoDbContext db, CurrentUser owner) => 25 | { 26 | return await db.Todos.Where(todo => todo.OwnerId == owner.Id).Select(t => t.AsTodoItem()).AsNoTracking().ToListAsync(); 27 | }); 28 | 29 | group.MapGet("/{id}", async Task, NotFound>> (TodoDbContext db, int id, CurrentUser owner) => 30 | { 31 | return await db.Todos.FindAsync(id) switch 32 | { 33 | Todo todo when todo.OwnerId == owner.Id || owner.IsAdmin => TypedResults.Ok(todo.AsTodoItem()), 34 | _ => TypedResults.NotFound() 35 | }; 36 | }); 37 | 38 | group.MapPost("/", async Task> (TodoDbContext db, TodoItem newTodo, CurrentUser owner) => 39 | { 40 | var todo = new Todo 41 | { 42 | Title = newTodo.Title, 43 | OwnerId = owner.Id 44 | }; 45 | 46 | db.Todos.Add(todo); 47 | await db.SaveChangesAsync(); 48 | 49 | return TypedResults.Created($"/todos/{todo.Id}", todo.AsTodoItem()); 50 | }); 51 | 52 | group.MapPut("/{id}", async Task> (TodoDbContext db, int id, TodoItem todo, CurrentUser owner) => 53 | { 54 | if (id != todo.Id) 55 | { 56 | return TypedResults.BadRequest(); 57 | } 58 | 59 | var rowsAffected = await db.Todos.Where(t => t.Id == id && (t.OwnerId == owner.Id || owner.IsAdmin)) 60 | .ExecuteUpdateAsync(updates => 61 | updates.SetProperty(t => t.IsComplete, todo.IsComplete) 62 | .SetProperty(t => t.Title, todo.Title)); 63 | 64 | return rowsAffected == 0 ? TypedResults.NotFound() : TypedResults.Ok(); 65 | }); 66 | 67 | group.MapDelete("/{id}", async Task> (TodoDbContext db, int id, CurrentUser owner) => 68 | { 69 | var rowsAffected = await db.Todos.Where(t => t.Id == id && (t.OwnerId == owner.Id || owner.IsAdmin)) 70 | .ExecuteDeleteAsync(); 71 | 72 | return rowsAffected == 0 ? TypedResults.NotFound() : TypedResults.Ok(); 73 | }); 74 | 75 | return group; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Todo.Api.Tests/TodoApplication.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.DataProtection; 2 | 3 | namespace TodoApi.Tests; 4 | 5 | internal class TodoApplication : WebApplicationFactory 6 | { 7 | private readonly SqliteConnection _sqliteConnection = new("Filename=:memory:"); 8 | 9 | public TodoDbContext CreateTodoDbContext() 10 | { 11 | var db = Services.GetRequiredService>().CreateDbContext(); 12 | db.Database.EnsureCreated(); 13 | return db; 14 | } 15 | 16 | public async Task CreateUserAsync(string username, string? password = null) 17 | { 18 | using var scope = Services.CreateScope(); 19 | var userManager = scope.ServiceProvider.GetRequiredService>(); 20 | var newUser = new TodoUser { Id = username, UserName = username }; 21 | var result = await userManager.CreateAsync(newUser, password ?? Guid.NewGuid().ToString()); 22 | Assert.True(result.Succeeded); 23 | } 24 | 25 | public HttpClient CreateClient(string id, bool isAdmin = false) 26 | { 27 | return CreateDefaultClient(new AuthHandler(Services, id, isAdmin)); 28 | } 29 | 30 | protected override IHost CreateHost(IHostBuilder builder) 31 | { 32 | // Open the connection, this creates the SQLite in-memory database, which will persist until the connection is closed 33 | _sqliteConnection.Open(); 34 | 35 | builder.ConfigureServices(services => 36 | { 37 | // We're going to use the factory from our tests 38 | services.AddDbContextFactory(); 39 | 40 | // We need to replace the configuration for the DbContext to use a different configured database 41 | services.AddDbContextOptions(o => o.UseSqlite(_sqliteConnection)); 42 | 43 | // Lower the requirements for the tests 44 | services.Configure(o => 45 | { 46 | o.Password.RequireNonAlphanumeric = false; 47 | o.Password.RequireDigit = false; 48 | o.Password.RequiredUniqueChars = 0; 49 | o.Password.RequiredLength = 1; 50 | o.Password.RequireLowercase = false; 51 | o.Password.RequireUppercase = false; 52 | }); 53 | 54 | // Since tests run in parallel, it's possible multiple servers will startup, 55 | // we use an ephemeral key provider and repository to avoid filesystem contention issues 56 | services.AddSingleton(); 57 | 58 | services.AddScoped(); 59 | }); 60 | 61 | return base.CreateHost(builder); 62 | } 63 | 64 | protected override void Dispose(bool disposing) 65 | { 66 | _sqliteConnection?.Dispose(); 67 | base.Dispose(disposing); 68 | } 69 | 70 | private sealed class AuthHandler(IServiceProvider services, string id, bool isAdmin) : DelegatingHandler 71 | { 72 | protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 73 | { 74 | await using var scope = services.CreateAsyncScope(); 75 | 76 | // Generate tokens 77 | var tokenService = scope.ServiceProvider.GetRequiredService(); 78 | 79 | var token = await tokenService.GenerateTokenAsync(id, isAdmin); 80 | 81 | request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); 82 | 83 | return await base.SendAsync(request, cancellationToken); 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /Todo.Web/Client/wwwroot/bootstrap/dist/css/bootstrap-reboot.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v5.1.0 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important} 8 | /*# sourceMappingURL=bootstrap-reboot.min.css.map */ -------------------------------------------------------------------------------- /Todo.Web/Client/wwwroot/bootstrap/dist/css/bootstrap-reboot.rtl.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v5.1.0 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-right:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-right:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:right}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:right;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:right}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}[type=email],[type=number],[type=tel],[type=url]{direction:ltr}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important} 8 | /*# sourceMappingURL=bootstrap-reboot.rtl.min.css.map */ -------------------------------------------------------------------------------- /TodoApp.ServiceDefaults/Extensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.AspNetCore.Diagnostics.HealthChecks; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Diagnostics.HealthChecks; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.ServiceDiscovery; 7 | using OpenTelemetry; 8 | using OpenTelemetry.Metrics; 9 | using OpenTelemetry.Trace; 10 | 11 | namespace Microsoft.Extensions.Hosting; 12 | 13 | // Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. 14 | // This project should be referenced by each service project in your solution. 15 | // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults 16 | public static class Extensions 17 | { 18 | public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder 19 | { 20 | builder.ConfigureOpenTelemetry(); 21 | 22 | builder.AddDefaultHealthChecks(); 23 | 24 | builder.Services.AddServiceDiscovery(); 25 | 26 | builder.Services.ConfigureHttpClientDefaults(http => 27 | { 28 | // Turn on resilience by default 29 | http.AddStandardResilienceHandler(); 30 | 31 | // Turn on service discovery by default 32 | http.AddServiceDiscovery(); 33 | }); 34 | 35 | // Uncomment the following to restrict the allowed schemes for service discovery. 36 | // builder.Services.Configure(options => 37 | // { 38 | // options.AllowedSchemes = ["https"]; 39 | // }); 40 | 41 | return builder; 42 | } 43 | 44 | public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder 45 | { 46 | builder.Logging.AddOpenTelemetry(logging => 47 | { 48 | logging.IncludeFormattedMessage = true; 49 | logging.IncludeScopes = true; 50 | }); 51 | 52 | builder.Services.AddOpenTelemetry() 53 | .WithMetrics(metrics => 54 | { 55 | metrics.AddAspNetCoreInstrumentation() 56 | .AddHttpClientInstrumentation() 57 | .AddRuntimeInstrumentation(); 58 | }) 59 | .WithTracing(tracing => 60 | { 61 | tracing.AddSource(builder.Environment.ApplicationName) 62 | .AddAspNetCoreInstrumentation() 63 | // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) 64 | //.AddGrpcClientInstrumentation() 65 | .AddHttpClientInstrumentation(); 66 | }); 67 | 68 | builder.AddOpenTelemetryExporters(); 69 | 70 | return builder; 71 | } 72 | 73 | private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder 74 | { 75 | var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); 76 | 77 | if (useOtlpExporter) 78 | { 79 | builder.Services.AddOpenTelemetry().UseOtlpExporter(); 80 | } 81 | 82 | // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) 83 | //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) 84 | //{ 85 | // builder.Services.AddOpenTelemetry() 86 | // .UseAzureMonitor(); 87 | //} 88 | 89 | return builder; 90 | } 91 | 92 | public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder 93 | { 94 | builder.Services.AddHealthChecks() 95 | // Add a default liveness check to ensure app is responsive 96 | .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); 97 | 98 | return builder; 99 | } 100 | 101 | public static WebApplication MapDefaultEndpoints(this WebApplication app) 102 | { 103 | // Adding health checks endpoints to applications in non-development environments has security implications. 104 | // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. 105 | if (app.Environment.IsDevelopment()) 106 | { 107 | // All health checks must pass for app to be considered ready to accept traffic after starting 108 | app.MapHealthChecks("/health"); 109 | 110 | // Only health checks tagged with the "live" tag must pass for app to be considered alive 111 | app.MapHealthChecks("/alive", new HealthCheckOptions 112 | { 113 | Predicate = r => r.Tags.Contains("live") 114 | }); 115 | } 116 | 117 | return app; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Todo application with ASP.NET Core 2 | 3 | [](https://github.com/davidfowl/TodoApi/actions/workflows/ci.yaml) 4 | 5 | This is a Todo application that features: 6 | - [**Todo.Web**](Todo.Web) - An ASP.NET Core hosted Blazor WASM front end application 7 | - [**Todo.Api**](Todo.Api) - An ASP.NET Core REST API backend using minimal APIs 8 | 9 |  10 | 11 | It showcases: 12 | - Blazor WebAssembly 13 | - Minimal APIs 14 | - Using EntityFramework and SQLite for data access 15 | - OpenAPI 16 | - User management with ASP.NET Core Identity 17 | - Cookie authentication 18 | - Bearer authentication 19 | - Proxying requests from the front end application server using YARP's IHttpForwarder 20 | - Rate Limiting 21 | - Writing integration tests for your REST API 22 | 23 | ## Prerequisites 24 | 25 | 1. [Install .NET 9](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) 26 | 2. [Aspire CLI](https://learn.microsoft.com/en-us/dotnet/aspire/cli/install) 27 | 28 | ### Database 29 | 30 | The application uses SQLite and entity framework. Aspire is used to bootstrap all dependencies. 31 | 32 | ### Running the application 33 | 34 | To run the application, run the [TodoApp.AppHost](TodoApp.AppHost) project. This uses Aspire to run both the [Todo.Web/Server](Todo.Web/Server) and [Todo.Api](Todo.Api). 35 | 36 | ## Optional 37 | 38 | ### Using the API standalone 39 | The Todo REST API can run standalone as well. You can run the [Todo.Api](Todo.Api) project and make requests to various endpoints using the Swagger UI (or a client of your choice): 40 | 41 | 42 | 43 | Before executing any requests, you need to create a user and get an auth token. 44 | 45 | 1. To create a new user, run the application and POST a JSON payload to `/users/register` endpoint: 46 | 47 | ```json 48 | { 49 | "email": "myuser@contoso.com", 50 | "password": "" 51 | } 52 | ``` 53 | 1. To get a token for the above user, hit the `/users/login` endpoint with the above user email and password. The response will look like this: 54 | 55 | ```json 56 | { 57 | "tokenType": "Bearer", 58 | "accessToken": "string", 59 | "expiresIn": , 60 | "refreshToken": "string" 61 | } 62 | ``` 63 | 64 | 1. You should be able to use the accessToken to make authenticated requests to the todo endpoints. 65 | 66 | ### Social authentication 67 | 68 | In addition to username and password, social authentication providers can be configured to work with this todo application. By default 69 | it supports Github, Google, and Microsoft accounts. 70 | 71 | Instructions for setting up each of these providers can be found at: 72 | - [Github](https://docs.github.com/en/developers/apps/building-oauth-apps) 73 | - [Microsoft](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/microsoft-logins) 74 | - [Google](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/google-logins) 75 | 76 | Once you obtain the client id and client secret, the configuration for these providers must be added with the following schema: 77 | 78 | ```JSON 79 | { 80 | "Authentication": { 81 | "Schemes": { 82 | "": { 83 | "ClientId": "xxx", 84 | "ClientSecret": "xxxx" 85 | } 86 | } 87 | } 88 | } 89 | ``` 90 | 91 | Or using environment variables: 92 | 93 | ``` 94 | Authentication__Schemes____ClientId=xxx 95 | Authentication__Schemes____ClientSecret=xxx 96 | ``` 97 | 98 | Or using user secrets: 99 | 100 | ``` 101 | dotnet user-secrets set Authentication:Schemes::ClientId xxx 102 | dotnet user-secrets set Authentication:Schemes::ClientSecret xxx 103 | ``` 104 | 105 | Other providers can be found [here](https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers#providers). 106 | These must be added to [AuthenticationExtensions](Todo.Web/Server/Authentication/AuthenticationExtensions.cs) as well. 107 | 108 | **NOTE: Don't store client secrets in configuration!** 109 | 110 | #### Auth0 111 | 112 | This sample has **Auth0** configured as an OIDC server. It can be configured with the following schema: 113 | 114 | ```JSON 115 | { 116 | "Authentication": { 117 | "Schemes": { 118 | "Auth0": { 119 | "Audience": "", 120 | "Domain": "", 121 | "ClientId": "", 122 | "ClientSecret": "" 123 | } 124 | } 125 | } 126 | } 127 | ``` 128 | 129 | Learn more about the Auth0 .NET SDK [here](https://github.com/auth0/auth0-aspnetcore-authentication). 130 | 131 | ### OpenTelemetry 132 | 133 | This app uses OpenTelemetry to collect logs, metrics and spans. You can see this 134 | using the [Aspire Dashboard](https://aspiredashboard.com/). 135 | -------------------------------------------------------------------------------- /Todo.Web/Server/Authentication/AuthenticationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Auth0.AspNetCore.Authentication; 2 | using Microsoft.AspNetCore.Authentication; 3 | using Microsoft.AspNetCore.Authentication.Cookies; 4 | using Microsoft.AspNetCore.Authentication.OpenIdConnect; 5 | using Microsoft.AspNetCore.Components.Authorization; 6 | 7 | namespace Todo.Web.Server; 8 | 9 | public static class AuthenticationExtensions 10 | { 11 | private delegate void ExternalAuthProvider(AuthenticationBuilder authenticationBuilder, Action configure); 12 | 13 | public static WebApplicationBuilder AddAuthentication(this WebApplicationBuilder builder) 14 | { 15 | // Our default scheme is cookies 16 | var authenticationBuilder = builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); 17 | 18 | // Add the default authentication cookie that will be used between the front end and 19 | // the backend. 20 | authenticationBuilder.AddCookie(); 21 | 22 | // This is the cookie that will store the user information from the external login provider 23 | authenticationBuilder.AddCookie(AuthenticationSchemes.ExternalScheme); 24 | 25 | // Add external auth providers based on configuration 26 | //{ 27 | // "Authentication": { 28 | // "Schemes": { 29 | // "": { 30 | // "ClientId": "xxx", 31 | // "ClientSecret": "xxxx" 32 | // etc.. 33 | // } 34 | // } 35 | // } 36 | //} 37 | 38 | // These are the list of external providers available to the application. 39 | // Many more are available from https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers 40 | var externalProviders = new Dictionary 41 | { 42 | ["GitHub"] = static (builder, configure) => builder.AddGitHub(configure), 43 | ["Google"] = static (builder, configure) => builder.AddGoogle(configure), 44 | ["Microsoft"] = static (builder, configure) => builder.AddMicrosoftAccount(configure), 45 | ["Auth0"] = static (builder, configure) => builder.AddAuth0WebAppAuthentication(configure) 46 | .WithAccessToken(configure), 47 | }; 48 | 49 | foreach (var (providerName, provider) in externalProviders) 50 | { 51 | var section = builder.Configuration.GetSection($"Authentication:Schemes:{providerName}"); 52 | 53 | if (section.Exists()) 54 | { 55 | provider(authenticationBuilder, options => 56 | { 57 | // Bind this section to the specified options 58 | section.Bind(options); 59 | 60 | // This will save the information in the external cookie 61 | if (options is RemoteAuthenticationOptions remoteAuthenticationOptions) 62 | { 63 | remoteAuthenticationOptions.SignInScheme = AuthenticationSchemes.ExternalScheme; 64 | } 65 | else if (options is Auth0WebAppOptions auth0WebAppOptions) 66 | { 67 | // Skip the cookie handler since we already add it 68 | auth0WebAppOptions.SkipCookieMiddleware = true; 69 | } 70 | }); 71 | 72 | if (providerName is "Auth0") 73 | { 74 | // Set this up once 75 | SetAuth0SignInScheme(builder); 76 | } 77 | } 78 | } 79 | 80 | // Add the service that resolves external providers so we can show them in the UI 81 | builder.Services.AddSingleton(); 82 | 83 | // Blazor auth services 84 | builder.Services.AddSingleton(); 85 | builder.Services.AddHttpContextAccessor(); 86 | 87 | return builder; 88 | 89 | static void SetAuth0SignInScheme(WebApplicationBuilder builder) 90 | { 91 | builder.Services.AddOptions(Auth0Constants.AuthenticationScheme) 92 | .PostConfigure(o => 93 | { 94 | // The Auth0 APIs don't let you set the sign in scheme, it defaults to the default sign in scheme. 95 | // Use named options to configure the underlying OpenIdConnectOptions's sign in scheme instead. 96 | o.SignInScheme = AuthenticationSchemes.ExternalScheme; 97 | }); 98 | } 99 | } 100 | 101 | private static readonly string ExternalProviderKey = "ExternalProviderName"; 102 | 103 | public static string? GetExternalProvider(this AuthenticationProperties properties) => 104 | properties.GetString(ExternalProviderKey); 105 | 106 | public static void SetExternalProvider(this AuthenticationProperties properties, string providerName) => 107 | properties.SetString(ExternalProviderKey, providerName); 108 | } 109 | -------------------------------------------------------------------------------- /Todo.Web/Server/AuthApi.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using Microsoft.AspNetCore.Authentication; 3 | using Microsoft.AspNetCore.Authentication.Cookies; 4 | using Microsoft.AspNetCore.DataProtection; 5 | 6 | namespace Todo.Web.Server; 7 | 8 | public static class AuthApi 9 | { 10 | public static RouteGroupBuilder MapAuth(this IEndpointRouteBuilder routes) 11 | { 12 | var group = routes.MapGroup("/auth"); 13 | 14 | group.MapPost("register", async (UserInfo userInfo, AuthClient client) => 15 | { 16 | // Retrieve the access token given the user info 17 | var token = await client.CreateUserAsync(userInfo); 18 | 19 | if (token is null) 20 | { 21 | return Results.Unauthorized(); 22 | } 23 | 24 | return SignIn(userInfo, token); 25 | }); 26 | 27 | group.MapPost("login", async (UserInfo userInfo, AuthClient client) => 28 | { 29 | // Retrieve the access token give the user info 30 | var token = await client.GetTokenAsync(userInfo); 31 | 32 | if (token is null) 33 | { 34 | return Results.Unauthorized(); 35 | } 36 | 37 | return SignIn(userInfo, token); 38 | }); 39 | 40 | group.MapPost("logout", async (HttpContext context) => 41 | { 42 | await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); 43 | 44 | // TODO: Support remote logout 45 | // If this is an external login then use it 46 | //var result = await context.AuthenticateAsync(); 47 | //if (result.Properties?.GetExternalProvider() is string providerName) 48 | //{ 49 | // await context.SignOutAsync(providerName, new() { RedirectUri = "/" }); 50 | //} 51 | }) 52 | .RequireAuthorization(); 53 | 54 | // External login 55 | group.MapGet("login/{provider}", (string provider) => 56 | { 57 | // Trigger the external login flow by issuing a challenge with the provider name. 58 | // This name maps to the registered authentication scheme names in AuthenticationExtensions.cs 59 | return Results.Challenge( 60 | properties: new() { RedirectUri = $"/auth/signin/{provider}" }, 61 | authenticationSchemes: [provider]); 62 | }); 63 | 64 | group.MapGet("signin/{provider}", async (string provider, AuthClient client, HttpContext context, IDataProtectionProvider dataProtectionProvider) => 65 | { 66 | // Grab the login information from the external login dance 67 | var result = await context.AuthenticateAsync(AuthenticationSchemes.ExternalScheme); 68 | 69 | if (result.Succeeded) 70 | { 71 | var principal = result.Principal; 72 | 73 | var id = principal.FindFirstValue(ClaimTypes.NameIdentifier)!; 74 | 75 | // TODO: We should have the user pick a user name to complete the external login dance 76 | // for now we'll prefer the email address 77 | var name = (principal.FindFirstValue(ClaimTypes.Email) ?? principal.Identity?.Name)!; 78 | 79 | // Protect the user id so it for transport 80 | var protector = dataProtectionProvider.CreateProtector(provider); 81 | 82 | var token = await client.GetOrCreateUserAsync(provider, new() { Username = name, ProviderKey = protector.Protect(id) }); 83 | 84 | if (token is not null) 85 | { 86 | // Write the login cookie 87 | await SignIn(id, name, token, provider).ExecuteAsync(context); 88 | } 89 | } 90 | 91 | // Delete the external cookie 92 | await context.SignOutAsync(AuthenticationSchemes.ExternalScheme); 93 | 94 | // TODO: Handle the failure somehow 95 | 96 | return Results.Redirect("/"); 97 | }); 98 | 99 | return group; 100 | } 101 | 102 | private static IResult SignIn(UserInfo userInfo, string token) 103 | { 104 | return SignIn(userInfo.Email, userInfo.Email, token, providerName: null); 105 | } 106 | 107 | private static IResult SignIn(string userId, string userName, string token, string? providerName) 108 | { 109 | var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme); 110 | identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId)); 111 | identity.AddClaim(new Claim(ClaimTypes.Name, userName)); 112 | 113 | var properties = new AuthenticationProperties(); 114 | 115 | // Store the external provider name so we can do remote sign out 116 | if (providerName is not null) 117 | { 118 | properties.SetExternalProvider(providerName); 119 | } 120 | 121 | properties.StoreTokens([ 122 | new AuthenticationToken { Name = TokenNames.AccessToken, Value = token } 123 | ]); 124 | 125 | return Results.SignIn(new ClaimsPrincipal(identity), 126 | properties: properties, 127 | authenticationScheme: CookieAuthenticationDefaults.AuthenticationScheme); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /TodoApp.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31717.71 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Todo.Api", "Todo.Api\Todo.Api.csproj", "{8B1F6F88-C84A-4D2F-A648-EDDCA6BD7943}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Todo.Api.Tests", "Todo.Api.Tests\Todo.Api.Tests.csproj", "{0A9F94AE-38A9-426D-A643-1F5582544318}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{7FAB2FEF-77C3-4519-AA2E-0CCCDFE1FC13}" 11 | ProjectSection(SolutionItems) = preProject 12 | .github\workflows\ci.yaml = .github\workflows\ci.yaml 13 | Directory.Build.props = Directory.Build.props 14 | Directory.Packages.props = Directory.Packages.props 15 | global.json = global.json 16 | nuget.config = nuget.config 17 | README.md = README.md 18 | EndProjectSection 19 | EndProject 20 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Todo.Web.Server", "Todo.Web\Server\Todo.Web.Server.csproj", "{9459B25A-3A37-43C4-A7DE-6113C1E2CDD7}" 21 | EndProject 22 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Todo.Web.Client", "Todo.Web\Client\Todo.Web.Client.csproj", "{699CCBA9-9DE8-48C3-9D2F-226E349C9253}" 23 | EndProject 24 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Todo.Web.Shared", "Todo.Web\Shared\Todo.Web.Shared.csproj", "{272942F6-94E8-4D6B-8AD8-C4CCA305836D}" 25 | EndProject 26 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TodoApp.AppHost", "TodoApp.AppHost\TodoApp.AppHost.csproj", "{4ECB456A-BC4A-4F99-A1D5-FE2DF1C4EA1D}" 27 | EndProject 28 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TodoApp.ServiceDefaults", "TodoApp.ServiceDefaults\TodoApp.ServiceDefaults.csproj", "{67B571B3-F4FB-46AA-888E-17A5A773610C}" 29 | EndProject 30 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" 31 | EndProject 32 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{5C99C740-7E6A-4FFD-B21F-E807C980C205}" 33 | EndProject 34 | Global 35 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 36 | Debug|Any CPU = Debug|Any CPU 37 | Release|Any CPU = Release|Any CPU 38 | EndGlobalSection 39 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 40 | {8B1F6F88-C84A-4D2F-A648-EDDCA6BD7943}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 41 | {8B1F6F88-C84A-4D2F-A648-EDDCA6BD7943}.Debug|Any CPU.Build.0 = Debug|Any CPU 42 | {8B1F6F88-C84A-4D2F-A648-EDDCA6BD7943}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {8B1F6F88-C84A-4D2F-A648-EDDCA6BD7943}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {0A9F94AE-38A9-426D-A643-1F5582544318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {0A9F94AE-38A9-426D-A643-1F5582544318}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {0A9F94AE-38A9-426D-A643-1F5582544318}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {0A9F94AE-38A9-426D-A643-1F5582544318}.Release|Any CPU.Build.0 = Release|Any CPU 48 | {9459B25A-3A37-43C4-A7DE-6113C1E2CDD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 49 | {9459B25A-3A37-43C4-A7DE-6113C1E2CDD7}.Debug|Any CPU.Build.0 = Debug|Any CPU 50 | {9459B25A-3A37-43C4-A7DE-6113C1E2CDD7}.Release|Any CPU.ActiveCfg = Release|Any CPU 51 | {9459B25A-3A37-43C4-A7DE-6113C1E2CDD7}.Release|Any CPU.Build.0 = Release|Any CPU 52 | {699CCBA9-9DE8-48C3-9D2F-226E349C9253}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 53 | {699CCBA9-9DE8-48C3-9D2F-226E349C9253}.Debug|Any CPU.Build.0 = Debug|Any CPU 54 | {699CCBA9-9DE8-48C3-9D2F-226E349C9253}.Release|Any CPU.ActiveCfg = Release|Any CPU 55 | {699CCBA9-9DE8-48C3-9D2F-226E349C9253}.Release|Any CPU.Build.0 = Release|Any CPU 56 | {272942F6-94E8-4D6B-8AD8-C4CCA305836D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 57 | {272942F6-94E8-4D6B-8AD8-C4CCA305836D}.Debug|Any CPU.Build.0 = Debug|Any CPU 58 | {272942F6-94E8-4D6B-8AD8-C4CCA305836D}.Release|Any CPU.ActiveCfg = Release|Any CPU 59 | {272942F6-94E8-4D6B-8AD8-C4CCA305836D}.Release|Any CPU.Build.0 = Release|Any CPU 60 | {4ECB456A-BC4A-4F99-A1D5-FE2DF1C4EA1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 61 | {4ECB456A-BC4A-4F99-A1D5-FE2DF1C4EA1D}.Debug|Any CPU.Build.0 = Debug|Any CPU 62 | {4ECB456A-BC4A-4F99-A1D5-FE2DF1C4EA1D}.Release|Any CPU.ActiveCfg = Release|Any CPU 63 | {4ECB456A-BC4A-4F99-A1D5-FE2DF1C4EA1D}.Release|Any CPU.Build.0 = Release|Any CPU 64 | {67B571B3-F4FB-46AA-888E-17A5A773610C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 65 | {67B571B3-F4FB-46AA-888E-17A5A773610C}.Debug|Any CPU.Build.0 = Debug|Any CPU 66 | {67B571B3-F4FB-46AA-888E-17A5A773610C}.Release|Any CPU.ActiveCfg = Release|Any CPU 67 | {67B571B3-F4FB-46AA-888E-17A5A773610C}.Release|Any CPU.Build.0 = Release|Any CPU 68 | EndGlobalSection 69 | GlobalSection(SolutionProperties) = preSolution 70 | HideSolutionNode = FALSE 71 | EndGlobalSection 72 | GlobalSection(NestedProjects) = preSolution 73 | {8B1F6F88-C84A-4D2F-A648-EDDCA6BD7943} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} 74 | {0A9F94AE-38A9-426D-A643-1F5582544318} = {5C99C740-7E6A-4FFD-B21F-E807C980C205} 75 | {9459B25A-3A37-43C4-A7DE-6113C1E2CDD7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} 76 | {699CCBA9-9DE8-48C3-9D2F-226E349C9253} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} 77 | {272942F6-94E8-4D6B-8AD8-C4CCA305836D} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} 78 | {4ECB456A-BC4A-4F99-A1D5-FE2DF1C4EA1D} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} 79 | {67B571B3-F4FB-46AA-888E-17A5A773610C} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} 80 | EndGlobalSection 81 | GlobalSection(ExtensibilityGlobals) = postSolution 82 | SolutionGuid = {28EAB679-0C71-494A-A1EC-D29DEDDD402E} 83 | EndGlobalSection 84 | EndGlobal 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # MSTest test Results 33 | [Tt]est[Rr]esult*/ 34 | [Bb]uild[Ll]og.* 35 | 36 | # NUNIT 37 | *.VisualState.xml 38 | TestResult.xml 39 | 40 | # Build Results of an ATL Project 41 | [Dd]ebugPS/ 42 | [Rr]eleasePS/ 43 | dlldata.c 44 | 45 | # .NET Core 46 | project.lock.json 47 | project.fragment.lock.json 48 | artifacts/ 49 | 50 | *_i.c 51 | *_p.c 52 | *_i.h 53 | *.ilk 54 | *.meta 55 | *.obj 56 | *.pch 57 | *.pdb 58 | *.pgc 59 | *.pgd 60 | *.rsp 61 | *.sbr 62 | *.tlb 63 | *.tli 64 | *.tlh 65 | *.tmp 66 | *.tmp_proj 67 | *.log 68 | *.vspscc 69 | *.vssscc 70 | .builds 71 | *.pidb 72 | *.svclog 73 | *.scc 74 | 75 | # Chutzpah Test files 76 | _Chutzpah* 77 | 78 | # Visual C++ cache files 79 | ipch/ 80 | *.aps 81 | *.ncb 82 | *.opendb 83 | *.opensdf 84 | *.sdf 85 | *.cachefile 86 | *.VC.db 87 | *.VC.VC.opendb 88 | 89 | # Visual Studio profiler 90 | *.psess 91 | *.vsp 92 | *.vspx 93 | *.sap 94 | 95 | # TFS 2012 Local Workspace 96 | $tf/ 97 | 98 | # Guidance Automation Toolkit 99 | *.gpState 100 | 101 | # ReSharper is a .NET coding add-in 102 | _ReSharper*/ 103 | *.[Rr]e[Ss]harper 104 | *.DotSettings.user 105 | 106 | # JustCode is a .NET coding add-in 107 | .JustCode 108 | 109 | # TeamCity is a build add-in 110 | _TeamCity* 111 | 112 | # DotCover is a Code Coverage Tool 113 | *.dotCover 114 | 115 | # Visual Studio code coverage results 116 | *.coverage 117 | *.coveragexml 118 | 119 | # NCrunch 120 | _NCrunch_* 121 | .*crunch*.local.xml 122 | nCrunchTemp_* 123 | 124 | # MightyMoose 125 | *.mm.* 126 | AutoTest.Net/ 127 | 128 | # Web workbench (sass) 129 | .sass-cache/ 130 | 131 | # Installshield output folder 132 | [Ee]xpress/ 133 | 134 | # DocProject is a documentation generator add-in 135 | DocProject/buildhelp/ 136 | DocProject/Help/*.HxT 137 | DocProject/Help/*.HxC 138 | DocProject/Help/*.hhc 139 | DocProject/Help/*.hhk 140 | DocProject/Help/*.hhp 141 | DocProject/Help/Html2 142 | DocProject/Help/html 143 | 144 | # Click-Once directory 145 | publish/ 146 | 147 | # Publish Web Output 148 | *.[Pp]ublish.xml 149 | *.azurePubxml 150 | # TODO: Comment the next line if you want to checkin your web deploy settings 151 | # but database connection strings (with potential passwords) will be unencrypted 152 | *.pubxml 153 | *.publishproj 154 | 155 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 156 | # checkin your Azure Web App publish settings, but sensitive information contained 157 | # in these scripts will be unencrypted 158 | PublishScripts/ 159 | 160 | # NuGet Packages 161 | *.nupkg 162 | # The packages folder can be ignored because of Package Restore 163 | **/packages/* 164 | # except build/, which is used as an MSBuild target. 165 | !**/packages/build/ 166 | # Uncomment if necessary however generally it will be regenerated when needed 167 | #!**/packages/repositories.config 168 | # NuGet v3's project.json files produces more ignorable files 169 | *.nuget.props 170 | *.nuget.targets 171 | 172 | # Microsoft Azure Build Output 173 | csx/ 174 | *.build.csdef 175 | 176 | # Microsoft Azure Emulator 177 | ecf/ 178 | rcf/ 179 | 180 | # Windows Store app package directories and files 181 | AppPackages/ 182 | BundleArtifacts/ 183 | Package.StoreAssociation.xml 184 | _pkginfo.txt 185 | 186 | # Visual Studio cache files 187 | # files ending in .cache can be ignored 188 | *.[Cc]ache 189 | # but keep track of directories ending in .cache 190 | !*.[Cc]ache/ 191 | 192 | # Others 193 | ClientBin/ 194 | ~$* 195 | *~ 196 | *.dbmdl 197 | *.dbproj.schemaview 198 | *.jfm 199 | *.pfx 200 | *.publishsettings 201 | orleans.codegen.cs 202 | 203 | # Since there are multiple workflows, uncomment next line to ignore bower_components 204 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 205 | #bower_components/ 206 | 207 | # RIA/Silverlight projects 208 | Generated_Code/ 209 | 210 | # Backup & report files from converting an old project file 211 | # to a newer Visual Studio version. Backup files are not needed, 212 | # because we have git ;-) 213 | _UpgradeReport_Files/ 214 | Backup*/ 215 | UpgradeLog*.XML 216 | UpgradeLog*.htm 217 | 218 | # SQL Server files 219 | *.mdf 220 | *.ldf 221 | *.ndf 222 | 223 | # Business Intelligence projects 224 | *.rdl.data 225 | *.bim.layout 226 | *.bim_*.settings 227 | 228 | # Microsoft Fakes 229 | FakesAssemblies/ 230 | 231 | # GhostDoc plugin setting file 232 | *.GhostDoc.xml 233 | 234 | # Node.js Tools for Visual Studio 235 | .ntvs_analysis.dat 236 | node_modules/ 237 | 238 | # Typescript v1 declaration files 239 | typings/ 240 | 241 | # Visual Studio 6 build log 242 | *.plg 243 | 244 | # Visual Studio 6 workspace options file 245 | *.opt 246 | 247 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 248 | *.vbw 249 | 250 | # Visual Studio LightSwitch build output 251 | **/*.HTMLClient/GeneratedArtifacts 252 | **/*.DesktopClient/GeneratedArtifacts 253 | **/*.DesktopClient/ModelManifest.xml 254 | **/*.Server/GeneratedArtifacts 255 | **/*.Server/ModelManifest.xml 256 | _Pvt_Extensions 257 | 258 | # Paket dependency manager 259 | .paket/paket.exe 260 | paket-files/ 261 | 262 | # FAKE - F# Make 263 | .fake/ 264 | 265 | # JetBrains Rider 266 | .idea/ 267 | *.sln.iml 268 | 269 | # CodeRush 270 | .cr/ 271 | 272 | # Python Tools for Visual Studio (PTVS) 273 | __pycache__/ 274 | *.pyc 275 | 276 | # Cake - Uncomment if you are using it 277 | # tools/** 278 | # !tools/packages.config 279 | 280 | # Telerik's JustMock configuration file 281 | *.jmconfig 282 | 283 | # BizTalk build output 284 | *.btp.cs 285 | *.btm.cs 286 | *.odx.cs 287 | *.xsd.cs 288 | 289 | # SQL Lite 290 | *.db 291 | 292 | # Visual Studio code 293 | .vscode/* 294 | !.vscode/extensions.json 295 | 296 | # MacOS 297 | .DS_Store 298 | *.db-shm 299 | *.db-wal 300 | /.tye 301 | -------------------------------------------------------------------------------- /Todo.Api.Tests/UserApiTests.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using Microsoft.AspNetCore.DataProtection; 3 | 4 | namespace TodoApi.Tests; 5 | 6 | public class UserApiTests 7 | { 8 | [Fact] 9 | public async Task CanCreateAUser() 10 | { 11 | await using var application = new TodoApplication(); 12 | await using var db = application.CreateTodoDbContext(); 13 | 14 | var client = application.CreateClient(); 15 | var response = await client.PostAsJsonAsync("/users/register", new UserInfo { Email = "todouser@todoapp.com", Password = "@pwd" }); 16 | 17 | Assert.True(response.IsSuccessStatusCode); 18 | 19 | var user = db.Users.Single(); 20 | Assert.NotNull(user); 21 | 22 | Assert.Equal("todouser@todoapp.com", user.UserName); 23 | } 24 | 25 | [Fact] 26 | public async Task MissingUserOrPasswordReturnsBadRequest() 27 | { 28 | await using var application = new TodoApplication(); 29 | await using var db = application.CreateTodoDbContext(); 30 | 31 | var client = application.CreateClient(); 32 | var response = await client.PostAsJsonAsync("/users/register", new UserInfo { Email = "todouser", Password = "" }); 33 | 34 | Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); 35 | 36 | var problemDetails = await response.Content.ReadFromJsonAsync(); 37 | Assert.NotNull(problemDetails); 38 | 39 | Assert.Equal("One or more validation errors occurred.", problemDetails.Title); 40 | Assert.NotEmpty(problemDetails.Errors); 41 | // TODO: Follow up on the new errors 42 | // Assert.Equal(new[] { "The Password field is required." }, problemDetails.Errors["Password"]); 43 | 44 | response = await client.PostAsJsonAsync("/users/register", new UserInfo { Email = "", Password = "password" }); 45 | 46 | Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); 47 | 48 | problemDetails = await response.Content.ReadFromJsonAsync(); 49 | Assert.NotNull(problemDetails); 50 | 51 | Assert.Equal("One or more validation errors occurred.", problemDetails.Title); 52 | Assert.NotEmpty(problemDetails.Errors); 53 | // Assert.Equal(new[] { "The Username field is required." }, problemDetails.Errors["Username"]); 54 | } 55 | 56 | 57 | 58 | [Fact] 59 | public async Task MissingUsernameOrProviderKeyReturnsBadRequest() 60 | { 61 | await using var application = new TodoApplication(); 62 | await using var db = application.CreateTodoDbContext(); 63 | 64 | var client = application.CreateClient(); 65 | var response = await client.PostAsJsonAsync("/users/token/Google", new ExternalUserInfo { Username = "todouser" }); 66 | 67 | Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); 68 | 69 | var problemDetails = await response.Content.ReadFromJsonAsync(); 70 | Assert.NotNull(problemDetails); 71 | 72 | Assert.Equal("One or more validation errors occurred.", problemDetails.Title); 73 | Assert.NotEmpty(problemDetails.Errors); 74 | Assert.Equal(new[] { $"The {nameof(ExternalUserInfo.ProviderKey)} field is required." }, problemDetails.Errors[nameof(ExternalUserInfo.ProviderKey)]); 75 | 76 | response = await client.PostAsJsonAsync("/users/token/Google", new ExternalUserInfo { ProviderKey = "somekey" }); 77 | 78 | Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); 79 | 80 | problemDetails = await response.Content.ReadFromJsonAsync(); 81 | Assert.NotNull(problemDetails); 82 | 83 | Assert.Equal("One or more validation errors occurred.", problemDetails.Title); 84 | Assert.NotEmpty(problemDetails.Errors); 85 | Assert.Equal(new[] { $"The Username field is required." }, problemDetails.Errors["Username"]); 86 | } 87 | 88 | [Fact] 89 | public async Task CanGetATokenForValidUser() 90 | { 91 | await using var application = new TodoApplication(); 92 | await using var db = application.CreateTodoDbContext(); 93 | await application.CreateUserAsync("todouser", "p@assw0rd1"); 94 | 95 | var client = application.CreateClient(); 96 | var response = await client.PostAsJsonAsync("/users/login", new UserInfo { Email = "todouser", Password = "p@assw0rd1" }); 97 | 98 | Assert.True(response.IsSuccessStatusCode); 99 | 100 | var token = await response.Content.ReadFromJsonAsync(); 101 | 102 | Assert.NotNull(token?.Token); 103 | 104 | // Check that the token is indeed valid 105 | 106 | var req = new HttpRequestMessage(HttpMethod.Get, "/todos"); 107 | req.Headers.Authorization = new("Bearer", token.Token); 108 | response = await client.SendAsync(req); 109 | 110 | Assert.True(response.IsSuccessStatusCode); 111 | } 112 | 113 | [Fact] 114 | public async Task CanGetATokenForExternalUser() 115 | { 116 | await using var application = new TodoApplication(); 117 | await using var db = application.CreateTodoDbContext(); 118 | 119 | var client = application.CreateClient(); 120 | 121 | var encryptedId = application.Services.GetRequiredService() 122 | .CreateProtector("Google") 123 | .Protect("1003"); 124 | 125 | var response = await client.PostAsJsonAsync("/users/token/Google", new ExternalUserInfo { Username = "todouser", ProviderKey = encryptedId }); 126 | 127 | Assert.True(response.IsSuccessStatusCode); 128 | 129 | var token = await response.Content.ReadFromJsonAsync(); 130 | 131 | Assert.NotNull(token?.Token); 132 | 133 | // Check that the token is indeed valid 134 | 135 | var req = new HttpRequestMessage(HttpMethod.Get, "/todos"); 136 | req.Headers.Authorization = new("Bearer", token.Token); 137 | response = await client.SendAsync(req); 138 | 139 | Assert.True(response.IsSuccessStatusCode); 140 | 141 | using var scope = application.Services.CreateScope(); 142 | var userManager = scope.ServiceProvider.GetRequiredService>(); 143 | var user = await userManager.FindByLoginAsync("Google", "1003"); 144 | Assert.NotNull(user); 145 | Assert.Equal("todouser", user.UserName); 146 | } 147 | 148 | [Fact] 149 | public async Task UnauthorizedForInvalidCredentials() 150 | { 151 | await using var application = new TodoApplication(); 152 | await using var db = application.CreateTodoDbContext(); 153 | await application.CreateUserAsync("todouser", "p@assw0rd1"); 154 | 155 | var client = application.CreateClient(); 156 | var response = await client.PostAsJsonAsync("/users/login", new UserInfo { Email = "todouser", Password = "prd1" }); 157 | 158 | Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); 159 | } 160 | 161 | class AuthToken 162 | { 163 | [JsonPropertyName("accessToken")] 164 | public string? Token { get; set; } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Todo.Web/Client/wwwroot/bootstrap/dist/css/bootstrap-reboot.rtl.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v5.1.0 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */ 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: border-box; 12 | } 13 | 14 | @media (prefers-reduced-motion: no-preference) { 15 | :root { 16 | scroll-behavior: smooth; 17 | } 18 | } 19 | 20 | body { 21 | margin: 0; 22 | font-family: var(--bs-body-font-family); 23 | font-size: var(--bs-body-font-size); 24 | font-weight: var(--bs-body-font-weight); 25 | line-height: var(--bs-body-line-height); 26 | color: var(--bs-body-color); 27 | text-align: var(--bs-body-text-align); 28 | background-color: var(--bs-body-bg); 29 | -webkit-text-size-adjust: 100%; 30 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 31 | } 32 | 33 | hr { 34 | margin: 1rem 0; 35 | color: inherit; 36 | background-color: currentColor; 37 | border: 0; 38 | opacity: 0.25; 39 | } 40 | 41 | hr:not([size]) { 42 | height: 1px; 43 | } 44 | 45 | h6, h5, h4, h3, h2, h1 { 46 | margin-top: 0; 47 | margin-bottom: 0.5rem; 48 | font-weight: 500; 49 | line-height: 1.2; 50 | } 51 | 52 | h1 { 53 | font-size: calc(1.375rem + 1.5vw); 54 | } 55 | @media (min-width: 1200px) { 56 | h1 { 57 | font-size: 2.5rem; 58 | } 59 | } 60 | 61 | h2 { 62 | font-size: calc(1.325rem + 0.9vw); 63 | } 64 | @media (min-width: 1200px) { 65 | h2 { 66 | font-size: 2rem; 67 | } 68 | } 69 | 70 | h3 { 71 | font-size: calc(1.3rem + 0.6vw); 72 | } 73 | @media (min-width: 1200px) { 74 | h3 { 75 | font-size: 1.75rem; 76 | } 77 | } 78 | 79 | h4 { 80 | font-size: calc(1.275rem + 0.3vw); 81 | } 82 | @media (min-width: 1200px) { 83 | h4 { 84 | font-size: 1.5rem; 85 | } 86 | } 87 | 88 | h5 { 89 | font-size: 1.25rem; 90 | } 91 | 92 | h6 { 93 | font-size: 1rem; 94 | } 95 | 96 | p { 97 | margin-top: 0; 98 | margin-bottom: 1rem; 99 | } 100 | 101 | abbr[title], 102 | abbr[data-bs-original-title] { 103 | -webkit-text-decoration: underline dotted; 104 | text-decoration: underline dotted; 105 | cursor: help; 106 | -webkit-text-decoration-skip-ink: none; 107 | text-decoration-skip-ink: none; 108 | } 109 | 110 | address { 111 | margin-bottom: 1rem; 112 | font-style: normal; 113 | line-height: inherit; 114 | } 115 | 116 | ol, 117 | ul { 118 | padding-right: 2rem; 119 | } 120 | 121 | ol, 122 | ul, 123 | dl { 124 | margin-top: 0; 125 | margin-bottom: 1rem; 126 | } 127 | 128 | ol ol, 129 | ul ul, 130 | ol ul, 131 | ul ol { 132 | margin-bottom: 0; 133 | } 134 | 135 | dt { 136 | font-weight: 700; 137 | } 138 | 139 | dd { 140 | margin-bottom: 0.5rem; 141 | margin-right: 0; 142 | } 143 | 144 | blockquote { 145 | margin: 0 0 1rem; 146 | } 147 | 148 | b, 149 | strong { 150 | font-weight: bolder; 151 | } 152 | 153 | small { 154 | font-size: 0.875em; 155 | } 156 | 157 | mark { 158 | padding: 0.2em; 159 | background-color: #fcf8e3; 160 | } 161 | 162 | sub, 163 | sup { 164 | position: relative; 165 | font-size: 0.75em; 166 | line-height: 0; 167 | vertical-align: baseline; 168 | } 169 | 170 | sub { 171 | bottom: -0.25em; 172 | } 173 | 174 | sup { 175 | top: -0.5em; 176 | } 177 | 178 | a { 179 | color: #0d6efd; 180 | text-decoration: underline; 181 | } 182 | a:hover { 183 | color: #0a58ca; 184 | } 185 | 186 | a:not([href]):not([class]), a:not([href]):not([class]):hover { 187 | color: inherit; 188 | text-decoration: none; 189 | } 190 | 191 | pre, 192 | code, 193 | kbd, 194 | samp { 195 | font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 196 | font-size: 1em; 197 | direction: ltr ; 198 | unicode-bidi: bidi-override; 199 | } 200 | 201 | pre { 202 | display: block; 203 | margin-top: 0; 204 | margin-bottom: 1rem; 205 | overflow: auto; 206 | font-size: 0.875em; 207 | } 208 | pre code { 209 | font-size: inherit; 210 | color: inherit; 211 | word-break: normal; 212 | } 213 | 214 | code { 215 | font-size: 0.875em; 216 | color: #d63384; 217 | word-wrap: break-word; 218 | } 219 | a > code { 220 | color: inherit; 221 | } 222 | 223 | kbd { 224 | padding: 0.2rem 0.4rem; 225 | font-size: 0.875em; 226 | color: #fff; 227 | background-color: #212529; 228 | border-radius: 0.2rem; 229 | } 230 | kbd kbd { 231 | padding: 0; 232 | font-size: 1em; 233 | font-weight: 700; 234 | } 235 | 236 | figure { 237 | margin: 0 0 1rem; 238 | } 239 | 240 | img, 241 | svg { 242 | vertical-align: middle; 243 | } 244 | 245 | table { 246 | caption-side: bottom; 247 | border-collapse: collapse; 248 | } 249 | 250 | caption { 251 | padding-top: 0.5rem; 252 | padding-bottom: 0.5rem; 253 | color: #6c757d; 254 | text-align: right; 255 | } 256 | 257 | th { 258 | text-align: inherit; 259 | text-align: -webkit-match-parent; 260 | } 261 | 262 | thead, 263 | tbody, 264 | tfoot, 265 | tr, 266 | td, 267 | th { 268 | border-color: inherit; 269 | border-style: solid; 270 | border-width: 0; 271 | } 272 | 273 | label { 274 | display: inline-block; 275 | } 276 | 277 | button { 278 | border-radius: 0; 279 | } 280 | 281 | button:focus:not(:focus-visible) { 282 | outline: 0; 283 | } 284 | 285 | input, 286 | button, 287 | select, 288 | optgroup, 289 | textarea { 290 | margin: 0; 291 | font-family: inherit; 292 | font-size: inherit; 293 | line-height: inherit; 294 | } 295 | 296 | button, 297 | select { 298 | text-transform: none; 299 | } 300 | 301 | [role=button] { 302 | cursor: pointer; 303 | } 304 | 305 | select { 306 | word-wrap: normal; 307 | } 308 | select:disabled { 309 | opacity: 1; 310 | } 311 | 312 | [list]::-webkit-calendar-picker-indicator { 313 | display: none; 314 | } 315 | 316 | button, 317 | [type=button], 318 | [type=reset], 319 | [type=submit] { 320 | -webkit-appearance: button; 321 | } 322 | button:not(:disabled), 323 | [type=button]:not(:disabled), 324 | [type=reset]:not(:disabled), 325 | [type=submit]:not(:disabled) { 326 | cursor: pointer; 327 | } 328 | 329 | ::-moz-focus-inner { 330 | padding: 0; 331 | border-style: none; 332 | } 333 | 334 | textarea { 335 | resize: vertical; 336 | } 337 | 338 | fieldset { 339 | min-width: 0; 340 | padding: 0; 341 | margin: 0; 342 | border: 0; 343 | } 344 | 345 | legend { 346 | float: right; 347 | width: 100%; 348 | padding: 0; 349 | margin-bottom: 0.5rem; 350 | font-size: calc(1.275rem + 0.3vw); 351 | line-height: inherit; 352 | } 353 | @media (min-width: 1200px) { 354 | legend { 355 | font-size: 1.5rem; 356 | } 357 | } 358 | legend + * { 359 | clear: right; 360 | } 361 | 362 | ::-webkit-datetime-edit-fields-wrapper, 363 | ::-webkit-datetime-edit-text, 364 | ::-webkit-datetime-edit-minute, 365 | ::-webkit-datetime-edit-hour-field, 366 | ::-webkit-datetime-edit-day-field, 367 | ::-webkit-datetime-edit-month-field, 368 | ::-webkit-datetime-edit-year-field { 369 | padding: 0; 370 | } 371 | 372 | ::-webkit-inner-spin-button { 373 | height: auto; 374 | } 375 | 376 | [type=search] { 377 | outline-offset: -2px; 378 | -webkit-appearance: textfield; 379 | } 380 | 381 | [type="tel"], 382 | [type="url"], 383 | [type="email"], 384 | [type="number"] { 385 | direction: ltr; 386 | } 387 | ::-webkit-search-decoration { 388 | -webkit-appearance: none; 389 | } 390 | 391 | ::-webkit-color-swatch-wrapper { 392 | padding: 0; 393 | } 394 | 395 | ::file-selector-button { 396 | font: inherit; 397 | } 398 | 399 | ::-webkit-file-upload-button { 400 | font: inherit; 401 | -webkit-appearance: button; 402 | } 403 | 404 | output { 405 | display: inline-block; 406 | } 407 | 408 | iframe { 409 | border: 0; 410 | } 411 | 412 | summary { 413 | display: list-item; 414 | cursor: pointer; 415 | } 416 | 417 | progress { 418 | vertical-align: baseline; 419 | } 420 | 421 | [hidden] { 422 | display: none !important; 423 | } 424 | /*# sourceMappingURL=bootstrap-reboot.rtl.css.map */ -------------------------------------------------------------------------------- /Todo.Web/Client/wwwroot/bootstrap/dist/css/bootstrap-reboot.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v5.1.0 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */ 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: border-box; 12 | } 13 | 14 | @media (prefers-reduced-motion: no-preference) { 15 | :root { 16 | scroll-behavior: smooth; 17 | } 18 | } 19 | 20 | body { 21 | margin: 0; 22 | font-family: var(--bs-body-font-family); 23 | font-size: var(--bs-body-font-size); 24 | font-weight: var(--bs-body-font-weight); 25 | line-height: var(--bs-body-line-height); 26 | color: var(--bs-body-color); 27 | text-align: var(--bs-body-text-align); 28 | background-color: var(--bs-body-bg); 29 | -webkit-text-size-adjust: 100%; 30 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 31 | } 32 | 33 | hr { 34 | margin: 1rem 0; 35 | color: inherit; 36 | background-color: currentColor; 37 | border: 0; 38 | opacity: 0.25; 39 | } 40 | 41 | hr:not([size]) { 42 | height: 1px; 43 | } 44 | 45 | h6, h5, h4, h3, h2, h1 { 46 | margin-top: 0; 47 | margin-bottom: 0.5rem; 48 | font-weight: 500; 49 | line-height: 1.2; 50 | } 51 | 52 | h1 { 53 | font-size: calc(1.375rem + 1.5vw); 54 | } 55 | @media (min-width: 1200px) { 56 | h1 { 57 | font-size: 2.5rem; 58 | } 59 | } 60 | 61 | h2 { 62 | font-size: calc(1.325rem + 0.9vw); 63 | } 64 | @media (min-width: 1200px) { 65 | h2 { 66 | font-size: 2rem; 67 | } 68 | } 69 | 70 | h3 { 71 | font-size: calc(1.3rem + 0.6vw); 72 | } 73 | @media (min-width: 1200px) { 74 | h3 { 75 | font-size: 1.75rem; 76 | } 77 | } 78 | 79 | h4 { 80 | font-size: calc(1.275rem + 0.3vw); 81 | } 82 | @media (min-width: 1200px) { 83 | h4 { 84 | font-size: 1.5rem; 85 | } 86 | } 87 | 88 | h5 { 89 | font-size: 1.25rem; 90 | } 91 | 92 | h6 { 93 | font-size: 1rem; 94 | } 95 | 96 | p { 97 | margin-top: 0; 98 | margin-bottom: 1rem; 99 | } 100 | 101 | abbr[title], 102 | abbr[data-bs-original-title] { 103 | -webkit-text-decoration: underline dotted; 104 | text-decoration: underline dotted; 105 | cursor: help; 106 | -webkit-text-decoration-skip-ink: none; 107 | text-decoration-skip-ink: none; 108 | } 109 | 110 | address { 111 | margin-bottom: 1rem; 112 | font-style: normal; 113 | line-height: inherit; 114 | } 115 | 116 | ol, 117 | ul { 118 | padding-left: 2rem; 119 | } 120 | 121 | ol, 122 | ul, 123 | dl { 124 | margin-top: 0; 125 | margin-bottom: 1rem; 126 | } 127 | 128 | ol ol, 129 | ul ul, 130 | ol ul, 131 | ul ol { 132 | margin-bottom: 0; 133 | } 134 | 135 | dt { 136 | font-weight: 700; 137 | } 138 | 139 | dd { 140 | margin-bottom: 0.5rem; 141 | margin-left: 0; 142 | } 143 | 144 | blockquote { 145 | margin: 0 0 1rem; 146 | } 147 | 148 | b, 149 | strong { 150 | font-weight: bolder; 151 | } 152 | 153 | small { 154 | font-size: 0.875em; 155 | } 156 | 157 | mark { 158 | padding: 0.2em; 159 | background-color: #fcf8e3; 160 | } 161 | 162 | sub, 163 | sup { 164 | position: relative; 165 | font-size: 0.75em; 166 | line-height: 0; 167 | vertical-align: baseline; 168 | } 169 | 170 | sub { 171 | bottom: -0.25em; 172 | } 173 | 174 | sup { 175 | top: -0.5em; 176 | } 177 | 178 | a { 179 | color: #0d6efd; 180 | text-decoration: underline; 181 | } 182 | a:hover { 183 | color: #0a58ca; 184 | } 185 | 186 | a:not([href]):not([class]), a:not([href]):not([class]):hover { 187 | color: inherit; 188 | text-decoration: none; 189 | } 190 | 191 | pre, 192 | code, 193 | kbd, 194 | samp { 195 | font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 196 | font-size: 1em; 197 | direction: ltr /* rtl:ignore */; 198 | unicode-bidi: bidi-override; 199 | } 200 | 201 | pre { 202 | display: block; 203 | margin-top: 0; 204 | margin-bottom: 1rem; 205 | overflow: auto; 206 | font-size: 0.875em; 207 | } 208 | pre code { 209 | font-size: inherit; 210 | color: inherit; 211 | word-break: normal; 212 | } 213 | 214 | code { 215 | font-size: 0.875em; 216 | color: #d63384; 217 | word-wrap: break-word; 218 | } 219 | a > code { 220 | color: inherit; 221 | } 222 | 223 | kbd { 224 | padding: 0.2rem 0.4rem; 225 | font-size: 0.875em; 226 | color: #fff; 227 | background-color: #212529; 228 | border-radius: 0.2rem; 229 | } 230 | kbd kbd { 231 | padding: 0; 232 | font-size: 1em; 233 | font-weight: 700; 234 | } 235 | 236 | figure { 237 | margin: 0 0 1rem; 238 | } 239 | 240 | img, 241 | svg { 242 | vertical-align: middle; 243 | } 244 | 245 | table { 246 | caption-side: bottom; 247 | border-collapse: collapse; 248 | } 249 | 250 | caption { 251 | padding-top: 0.5rem; 252 | padding-bottom: 0.5rem; 253 | color: #6c757d; 254 | text-align: left; 255 | } 256 | 257 | th { 258 | text-align: inherit; 259 | text-align: -webkit-match-parent; 260 | } 261 | 262 | thead, 263 | tbody, 264 | tfoot, 265 | tr, 266 | td, 267 | th { 268 | border-color: inherit; 269 | border-style: solid; 270 | border-width: 0; 271 | } 272 | 273 | label { 274 | display: inline-block; 275 | } 276 | 277 | button { 278 | border-radius: 0; 279 | } 280 | 281 | button:focus:not(:focus-visible) { 282 | outline: 0; 283 | } 284 | 285 | input, 286 | button, 287 | select, 288 | optgroup, 289 | textarea { 290 | margin: 0; 291 | font-family: inherit; 292 | font-size: inherit; 293 | line-height: inherit; 294 | } 295 | 296 | button, 297 | select { 298 | text-transform: none; 299 | } 300 | 301 | [role=button] { 302 | cursor: pointer; 303 | } 304 | 305 | select { 306 | word-wrap: normal; 307 | } 308 | select:disabled { 309 | opacity: 1; 310 | } 311 | 312 | [list]::-webkit-calendar-picker-indicator { 313 | display: none; 314 | } 315 | 316 | button, 317 | [type=button], 318 | [type=reset], 319 | [type=submit] { 320 | -webkit-appearance: button; 321 | } 322 | button:not(:disabled), 323 | [type=button]:not(:disabled), 324 | [type=reset]:not(:disabled), 325 | [type=submit]:not(:disabled) { 326 | cursor: pointer; 327 | } 328 | 329 | ::-moz-focus-inner { 330 | padding: 0; 331 | border-style: none; 332 | } 333 | 334 | textarea { 335 | resize: vertical; 336 | } 337 | 338 | fieldset { 339 | min-width: 0; 340 | padding: 0; 341 | margin: 0; 342 | border: 0; 343 | } 344 | 345 | legend { 346 | float: left; 347 | width: 100%; 348 | padding: 0; 349 | margin-bottom: 0.5rem; 350 | font-size: calc(1.275rem + 0.3vw); 351 | line-height: inherit; 352 | } 353 | @media (min-width: 1200px) { 354 | legend { 355 | font-size: 1.5rem; 356 | } 357 | } 358 | legend + * { 359 | clear: left; 360 | } 361 | 362 | ::-webkit-datetime-edit-fields-wrapper, 363 | ::-webkit-datetime-edit-text, 364 | ::-webkit-datetime-edit-minute, 365 | ::-webkit-datetime-edit-hour-field, 366 | ::-webkit-datetime-edit-day-field, 367 | ::-webkit-datetime-edit-month-field, 368 | ::-webkit-datetime-edit-year-field { 369 | padding: 0; 370 | } 371 | 372 | ::-webkit-inner-spin-button { 373 | height: auto; 374 | } 375 | 376 | [type=search] { 377 | outline-offset: -2px; 378 | -webkit-appearance: textfield; 379 | } 380 | 381 | /* rtl:raw: 382 | [type="tel"], 383 | [type="url"], 384 | [type="email"], 385 | [type="number"] { 386 | direction: ltr; 387 | } 388 | */ 389 | ::-webkit-search-decoration { 390 | -webkit-appearance: none; 391 | } 392 | 393 | ::-webkit-color-swatch-wrapper { 394 | padding: 0; 395 | } 396 | 397 | ::file-selector-button { 398 | font: inherit; 399 | } 400 | 401 | ::-webkit-file-upload-button { 402 | font: inherit; 403 | -webkit-appearance: button; 404 | } 405 | 406 | output { 407 | display: inline-block; 408 | } 409 | 410 | iframe { 411 | border: 0; 412 | } 413 | 414 | summary { 415 | display: list-item; 416 | cursor: pointer; 417 | } 418 | 419 | progress { 420 | vertical-align: baseline; 421 | } 422 | 423 | [hidden] { 424 | display: none !important; 425 | } 426 | 427 | /*# sourceMappingURL=bootstrap-reboot.css.map */ -------------------------------------------------------------------------------- /Todo.Api.Tests/TodoApiTests.cs: -------------------------------------------------------------------------------- 1 | namespace TodoApi.Tests; 2 | 3 | public class TodoApiTests 4 | { 5 | [Fact] 6 | public async Task GetTodos() 7 | { 8 | var userId = "34"; 9 | 10 | await using var application = new TodoApplication(); 11 | await using var db = application.CreateTodoDbContext(); 12 | await application.CreateUserAsync(userId); 13 | 14 | db.Todos.Add(new Todo { Title = "Thing one I have to do", OwnerId = userId }); 15 | 16 | await db.SaveChangesAsync(); 17 | 18 | var client = application.CreateClient(userId); 19 | var todos = await client.GetFromJsonAsync>("/todos"); 20 | Assert.NotNull(todos); 21 | 22 | var todo = Assert.Single(todos); 23 | Assert.Equal("Thing one I have to do", todo.Title); 24 | } 25 | 26 | [Fact] 27 | public async Task GetTodosWithoutDbUser() 28 | { 29 | var userId = "34"; 30 | 31 | await using var application = new TodoApplication(); 32 | await using var db = application.CreateTodoDbContext(); 33 | 34 | var client = application.CreateClient(userId); 35 | var response = await client.GetAsync("/todos"); 36 | 37 | Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); 38 | } 39 | 40 | [Fact] 41 | public async Task PostTodos() 42 | { 43 | var userId = "34"; 44 | await using var application = new TodoApplication(); 45 | await using var db = application.CreateTodoDbContext(); 46 | await application.CreateUserAsync(userId); 47 | 48 | var client = application.CreateClient(userId); 49 | var response = await client.PostAsJsonAsync("/todos", new TodoItem { Title = "I want to do this thing tomorrow" }); 50 | 51 | Assert.Equal(HttpStatusCode.Created, response.StatusCode); 52 | 53 | var todos = await client.GetFromJsonAsync>("/todos"); 54 | Assert.NotNull(todos); 55 | 56 | var todo = Assert.Single(todos); 57 | Assert.Equal("I want to do this thing tomorrow", todo.Title); 58 | Assert.False(todo.IsComplete); 59 | } 60 | 61 | [Fact] 62 | public async Task DeleteTodos() 63 | { 64 | var userId = "34"; 65 | 66 | await using var application = new TodoApplication(); 67 | await using var db = application.CreateTodoDbContext(); 68 | await application.CreateUserAsync(userId); 69 | 70 | db.Todos.Add(new Todo { Title = "I want to do this thing tomorrow", OwnerId = userId }); 71 | 72 | await db.SaveChangesAsync(); 73 | 74 | var client = application.CreateClient(userId); 75 | 76 | var todo = db.Todos.FirstOrDefault(); 77 | Assert.NotNull(todo); 78 | Assert.Equal("I want to do this thing tomorrow", todo.Title); 79 | Assert.False(todo.IsComplete); 80 | 81 | var response = await client.DeleteAsync($"/todos/{todo.Id}"); 82 | 83 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 84 | 85 | todo = db.Todos.FirstOrDefault(); 86 | Assert.Null(todo); 87 | } 88 | 89 | [Fact] 90 | public async Task CanOnlyGetTodosPostedBySameUser() 91 | { 92 | var userId0 = "34"; 93 | var userId1 = "35"; 94 | 95 | await using var application = new TodoApplication(); 96 | await using var db = application.CreateTodoDbContext(); 97 | await application.CreateUserAsync(userId0); 98 | await application.CreateUserAsync(userId1); 99 | 100 | db.Todos.Add(new Todo { Title = "I want to do this thing tomorrow", OwnerId = userId0 }); 101 | 102 | await db.SaveChangesAsync(); 103 | 104 | var client0 = application.CreateClient(userId0); 105 | var client1 = application.CreateClient(userId1); 106 | 107 | var todos0 = await client0.GetFromJsonAsync>("/todos"); 108 | Assert.NotNull(todos0); 109 | 110 | var todos1 = await client1.GetFromJsonAsync>("/todos"); 111 | Assert.NotNull(todos1); 112 | 113 | Assert.Empty(todos1); 114 | 115 | var todo = Assert.Single(todos0); 116 | Assert.Equal("I want to do this thing tomorrow", todo.Title); 117 | Assert.False(todo.IsComplete); 118 | 119 | var todo0 = await client0.GetFromJsonAsync($"/todos/{todo.Id}"); 120 | Assert.NotNull(todo0); 121 | 122 | var response = await client1.GetAsync($"/todos/{todo.Id}"); 123 | Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); 124 | } 125 | 126 | [Fact] 127 | public async Task PostingTodoWithoutTitleReturnsProblemDetails() 128 | { 129 | var userId = "34"; 130 | await using var application = new TodoApplication(); 131 | await using var db = application.CreateTodoDbContext(); 132 | await application.CreateUserAsync(userId); 133 | 134 | var client = application.CreateClient(userId); 135 | var response = await client.PostAsJsonAsync("/todos", new TodoItem { }); 136 | 137 | Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); 138 | 139 | var problemDetails = await response.Content.ReadFromJsonAsync(); 140 | Assert.NotNull(problemDetails); 141 | 142 | Assert.Equal("One or more validation errors occurred.", problemDetails.Title); 143 | Assert.NotEmpty(problemDetails.Errors); 144 | Assert.Equal(new[] { "The Title field is required." }, problemDetails.Errors["Title"]); 145 | } 146 | 147 | [Fact] 148 | public async Task CannotDeleteUnownedTodos() 149 | { 150 | var userId0 = "34"; 151 | var userId1 = "35"; 152 | 153 | await using var application = new TodoApplication(); 154 | await using var db = application.CreateTodoDbContext(); 155 | await application.CreateUserAsync(userId0); 156 | await application.CreateUserAsync(userId1); 157 | 158 | db.Todos.Add(new Todo { Title = "I want to do this thing tomorrow", OwnerId = userId0 }); 159 | 160 | await db.SaveChangesAsync(); 161 | 162 | var client0 = application.CreateClient(userId0); 163 | var client1 = application.CreateClient(userId1); 164 | 165 | var todos = await client0.GetFromJsonAsync>("/todos"); 166 | Assert.NotNull(todos); 167 | 168 | var todo = Assert.Single(todos); 169 | Assert.Equal("I want to do this thing tomorrow", todo.Title); 170 | Assert.False(todo.IsComplete); 171 | 172 | var response = await client1.DeleteAsync($"/todos/{todo.Id}"); 173 | 174 | Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); 175 | 176 | var undeletedTodo = db.Todos.FirstOrDefault(); 177 | Assert.NotNull(undeletedTodo); 178 | 179 | Assert.Equal(todo.Title, undeletedTodo.Title); 180 | Assert.Equal(todo.Id, undeletedTodo.Id); 181 | } 182 | 183 | [Fact] 184 | public async Task AdminCanDeleteUnownedTodos() 185 | { 186 | var userId = "34"; 187 | var adminUserId = "35"; 188 | 189 | await using var application = new TodoApplication(); 190 | await using var db = application.CreateTodoDbContext(); 191 | await application.CreateUserAsync(userId); 192 | await application.CreateUserAsync(adminUserId); 193 | 194 | db.Todos.Add(new Todo { Title = "I want to do this thing tomorrow", OwnerId = userId }); 195 | 196 | await db.SaveChangesAsync(); 197 | 198 | var client = application.CreateClient(userId); 199 | var adminClient = application.CreateClient(adminUserId, isAdmin: true); 200 | 201 | var todos = await client.GetFromJsonAsync>("/todos"); 202 | Assert.NotNull(todos); 203 | 204 | var todo = Assert.Single(todos); 205 | Assert.Equal("I want to do this thing tomorrow", todo.Title); 206 | Assert.False(todo.IsComplete); 207 | 208 | var response = await adminClient.DeleteAsync($"/todos/{todo.Id}"); 209 | 210 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 211 | 212 | var undeletedTodo = db.Todos.FirstOrDefault(); 213 | Assert.Null(undeletedTodo); 214 | } 215 | 216 | /// 217 | /// Verify that user can only update owned Todos 218 | /// Change userId 219 | /// 220 | /// 221 | [Fact] 222 | public async Task CanUpdateOwnedTodos() 223 | { 224 | var ownerId = "34"; 225 | var userId = "34"; 226 | await using var application = new TodoApplication(); 227 | await using var db = application.CreateTodoDbContext(); 228 | await application.CreateUserAsync(userId); 229 | 230 | db.Todos.Add(new Todo { Title = "I want to do this thing tomorrow", OwnerId = ownerId }); 231 | 232 | await db.SaveChangesAsync(); 233 | 234 | // Create API Client 235 | var client = application.CreateClient(userId); 236 | 237 | var todos = await client.GetFromJsonAsync>("/todos"); 238 | 239 | Assert.NotNull(todos); 240 | 241 | var todo = Assert.Single(todos); 242 | 243 | //update the status 244 | todo.IsComplete = true; 245 | 246 | var response = await client.PutAsJsonAsync($"todos/{todo.Id}", todo); 247 | 248 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 249 | 250 | // Verify the update 251 | todos = await client.GetFromJsonAsync>("/todos"); 252 | Assert.NotNull(todos); 253 | var updatedTodo = Assert.Single(todos); 254 | Assert.NotNull(updatedTodo); 255 | Assert.True(updatedTodo.IsComplete); 256 | 257 | } 258 | 259 | /// 260 | /// Check if Admin can update any todo item 261 | /// 262 | /// 263 | [Fact] 264 | public async Task AdminCanUpdateUnownedTodos() 265 | { 266 | var userId = "34"; 267 | var adminUserId = "35"; 268 | 269 | await using var application = new TodoApplication(); 270 | await using var db = application.CreateTodoDbContext(); 271 | await application.CreateUserAsync(userId); 272 | await application.CreateUserAsync(adminUserId); 273 | 274 | db.Todos.Add(new Todo { Title = "I want to do this thing tomorrow", OwnerId = userId }); 275 | 276 | await db.SaveChangesAsync(); 277 | 278 | var client = application.CreateClient(userId); 279 | var adminClient = application.CreateClient(adminUserId, isAdmin: true); 280 | 281 | var todos = await client.GetFromJsonAsync>("/todos"); 282 | Assert.NotNull(todos); 283 | 284 | var todo = Assert.Single(todos); 285 | 286 | //Update the todo 287 | todo.IsComplete = true; 288 | 289 | var response = await adminClient.PutAsJsonAsync($"/todos/{todo.Id}", todo); 290 | 291 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 292 | 293 | // Verify the changes 294 | todos = await client.GetFromJsonAsync>("/todos"); 295 | Assert.NotNull(todos); 296 | var updatedTodo = Assert.Single(todos); 297 | Assert.NotNull(updatedTodo); 298 | Assert.True(updatedTodo.IsComplete); 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /Todo.Api/Migrations/TodoDbContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 6 | using TodoApi; 7 | 8 | #nullable disable 9 | 10 | namespace TodoApi.Migrations 11 | { 12 | [DbContext(typeof(TodoDbContext))] 13 | partial class TodoDbContextModelSnapshot : ModelSnapshot 14 | { 15 | protected override void BuildModel(ModelBuilder modelBuilder) 16 | { 17 | #pragma warning disable 612, 618 18 | modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); 19 | 20 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => 21 | { 22 | b.Property("Id") 23 | .HasColumnType("TEXT"); 24 | 25 | b.Property("ConcurrencyStamp") 26 | .IsConcurrencyToken() 27 | .HasColumnType("TEXT"); 28 | 29 | b.Property("Name") 30 | .HasMaxLength(256) 31 | .HasColumnType("TEXT"); 32 | 33 | b.Property("NormalizedName") 34 | .HasMaxLength(256) 35 | .HasColumnType("TEXT"); 36 | 37 | b.HasKey("Id"); 38 | 39 | b.HasIndex("NormalizedName") 40 | .IsUnique() 41 | .HasDatabaseName("RoleNameIndex"); 42 | 43 | b.ToTable("AspNetRoles", (string)null); 44 | }); 45 | 46 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => 47 | { 48 | b.Property("Id") 49 | .ValueGeneratedOnAdd() 50 | .HasColumnType("INTEGER"); 51 | 52 | b.Property("ClaimType") 53 | .HasColumnType("TEXT"); 54 | 55 | b.Property("ClaimValue") 56 | .HasColumnType("TEXT"); 57 | 58 | b.Property("RoleId") 59 | .IsRequired() 60 | .HasColumnType("TEXT"); 61 | 62 | b.HasKey("Id"); 63 | 64 | b.HasIndex("RoleId"); 65 | 66 | b.ToTable("AspNetRoleClaims", (string)null); 67 | }); 68 | 69 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 70 | { 71 | b.Property("Id") 72 | .ValueGeneratedOnAdd() 73 | .HasColumnType("INTEGER"); 74 | 75 | b.Property("ClaimType") 76 | .HasColumnType("TEXT"); 77 | 78 | b.Property("ClaimValue") 79 | .HasColumnType("TEXT"); 80 | 81 | b.Property("UserId") 82 | .IsRequired() 83 | .HasColumnType("TEXT"); 84 | 85 | b.HasKey("Id"); 86 | 87 | b.HasIndex("UserId"); 88 | 89 | b.ToTable("AspNetUserClaims", (string)null); 90 | }); 91 | 92 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 93 | { 94 | b.Property("LoginProvider") 95 | .HasColumnType("TEXT"); 96 | 97 | b.Property("ProviderKey") 98 | .HasColumnType("TEXT"); 99 | 100 | b.Property("ProviderDisplayName") 101 | .HasColumnType("TEXT"); 102 | 103 | b.Property("UserId") 104 | .IsRequired() 105 | .HasColumnType("TEXT"); 106 | 107 | b.HasKey("LoginProvider", "ProviderKey"); 108 | 109 | b.HasIndex("UserId"); 110 | 111 | b.ToTable("AspNetUserLogins", (string)null); 112 | }); 113 | 114 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => 115 | { 116 | b.Property("UserId") 117 | .HasColumnType("TEXT"); 118 | 119 | b.Property("RoleId") 120 | .HasColumnType("TEXT"); 121 | 122 | b.HasKey("UserId", "RoleId"); 123 | 124 | b.HasIndex("RoleId"); 125 | 126 | b.ToTable("AspNetUserRoles", (string)null); 127 | }); 128 | 129 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 130 | { 131 | b.Property("UserId") 132 | .HasColumnType("TEXT"); 133 | 134 | b.Property("LoginProvider") 135 | .HasColumnType("TEXT"); 136 | 137 | b.Property("Name") 138 | .HasColumnType("TEXT"); 139 | 140 | b.Property("Value") 141 | .HasColumnType("TEXT"); 142 | 143 | b.HasKey("UserId", "LoginProvider", "Name"); 144 | 145 | b.ToTable("AspNetUserTokens", (string)null); 146 | }); 147 | 148 | modelBuilder.Entity("Todo", b => 149 | { 150 | b.Property("Id") 151 | .ValueGeneratedOnAdd() 152 | .HasColumnType("INTEGER"); 153 | 154 | b.Property("IsComplete") 155 | .HasColumnType("INTEGER"); 156 | 157 | b.Property("OwnerId") 158 | .IsRequired() 159 | .HasColumnType("TEXT"); 160 | 161 | b.Property("Title") 162 | .IsRequired() 163 | .HasColumnType("TEXT"); 164 | 165 | b.HasKey("Id"); 166 | 167 | b.HasIndex("OwnerId"); 168 | 169 | b.ToTable("Todos"); 170 | }); 171 | 172 | modelBuilder.Entity("TodoApi.TodoUser", b => 173 | { 174 | b.Property("Id") 175 | .HasColumnType("TEXT"); 176 | 177 | b.Property("AccessFailedCount") 178 | .HasColumnType("INTEGER"); 179 | 180 | b.Property("ConcurrencyStamp") 181 | .IsConcurrencyToken() 182 | .HasColumnType("TEXT"); 183 | 184 | b.Property("Email") 185 | .HasMaxLength(256) 186 | .HasColumnType("TEXT"); 187 | 188 | b.Property("EmailConfirmed") 189 | .HasColumnType("INTEGER"); 190 | 191 | b.Property("LockoutEnabled") 192 | .HasColumnType("INTEGER"); 193 | 194 | b.Property("LockoutEnd") 195 | .HasColumnType("TEXT"); 196 | 197 | b.Property("NormalizedEmail") 198 | .HasMaxLength(256) 199 | .HasColumnType("TEXT"); 200 | 201 | b.Property("NormalizedUserName") 202 | .HasMaxLength(256) 203 | .HasColumnType("TEXT"); 204 | 205 | b.Property("PasswordHash") 206 | .HasColumnType("TEXT"); 207 | 208 | b.Property("PhoneNumber") 209 | .HasColumnType("TEXT"); 210 | 211 | b.Property("PhoneNumberConfirmed") 212 | .HasColumnType("INTEGER"); 213 | 214 | b.Property("SecurityStamp") 215 | .HasColumnType("TEXT"); 216 | 217 | b.Property("TwoFactorEnabled") 218 | .HasColumnType("INTEGER"); 219 | 220 | b.Property("UserName") 221 | .HasMaxLength(256) 222 | .HasColumnType("TEXT"); 223 | 224 | b.HasKey("Id"); 225 | 226 | b.HasIndex("NormalizedEmail") 227 | .HasDatabaseName("EmailIndex"); 228 | 229 | b.HasIndex("NormalizedUserName") 230 | .IsUnique() 231 | .HasDatabaseName("UserNameIndex"); 232 | 233 | b.ToTable("AspNetUsers", (string)null); 234 | }); 235 | 236 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => 237 | { 238 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 239 | .WithMany() 240 | .HasForeignKey("RoleId") 241 | .OnDelete(DeleteBehavior.Cascade) 242 | .IsRequired(); 243 | }); 244 | 245 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 246 | { 247 | b.HasOne("TodoApi.TodoUser", null) 248 | .WithMany() 249 | .HasForeignKey("UserId") 250 | .OnDelete(DeleteBehavior.Cascade) 251 | .IsRequired(); 252 | }); 253 | 254 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 255 | { 256 | b.HasOne("TodoApi.TodoUser", null) 257 | .WithMany() 258 | .HasForeignKey("UserId") 259 | .OnDelete(DeleteBehavior.Cascade) 260 | .IsRequired(); 261 | }); 262 | 263 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => 264 | { 265 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 266 | .WithMany() 267 | .HasForeignKey("RoleId") 268 | .OnDelete(DeleteBehavior.Cascade) 269 | .IsRequired(); 270 | 271 | b.HasOne("TodoApi.TodoUser", null) 272 | .WithMany() 273 | .HasForeignKey("UserId") 274 | .OnDelete(DeleteBehavior.Cascade) 275 | .IsRequired(); 276 | }); 277 | 278 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 279 | { 280 | b.HasOne("TodoApi.TodoUser", null) 281 | .WithMany() 282 | .HasForeignKey("UserId") 283 | .OnDelete(DeleteBehavior.Cascade) 284 | .IsRequired(); 285 | }); 286 | 287 | modelBuilder.Entity("Todo", b => 288 | { 289 | b.HasOne("TodoApi.TodoUser", null) 290 | .WithMany() 291 | .HasForeignKey("OwnerId") 292 | .OnDelete(DeleteBehavior.Cascade) 293 | .IsRequired(); 294 | }); 295 | #pragma warning restore 612, 618 296 | } 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /Todo.Api/Migrations/20230714032431_ForeignKeyChange.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using TodoApi; 8 | 9 | #nullable disable 10 | 11 | namespace TodoApi.Migrations 12 | { 13 | [DbContext(typeof(TodoDbContext))] 14 | [Migration("20230714032431_ForeignKeyChange")] 15 | partial class ForeignKeyChange 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); 22 | 23 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => 24 | { 25 | b.Property("Id") 26 | .HasColumnType("TEXT"); 27 | 28 | b.Property("ConcurrencyStamp") 29 | .IsConcurrencyToken() 30 | .HasColumnType("TEXT"); 31 | 32 | b.Property("Name") 33 | .HasMaxLength(256) 34 | .HasColumnType("TEXT"); 35 | 36 | b.Property("NormalizedName") 37 | .HasMaxLength(256) 38 | .HasColumnType("TEXT"); 39 | 40 | b.HasKey("Id"); 41 | 42 | b.HasIndex("NormalizedName") 43 | .IsUnique() 44 | .HasDatabaseName("RoleNameIndex"); 45 | 46 | b.ToTable("AspNetRoles", (string)null); 47 | }); 48 | 49 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => 50 | { 51 | b.Property("Id") 52 | .ValueGeneratedOnAdd() 53 | .HasColumnType("INTEGER"); 54 | 55 | b.Property("ClaimType") 56 | .HasColumnType("TEXT"); 57 | 58 | b.Property("ClaimValue") 59 | .HasColumnType("TEXT"); 60 | 61 | b.Property("RoleId") 62 | .IsRequired() 63 | .HasColumnType("TEXT"); 64 | 65 | b.HasKey("Id"); 66 | 67 | b.HasIndex("RoleId"); 68 | 69 | b.ToTable("AspNetRoleClaims", (string)null); 70 | }); 71 | 72 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 73 | { 74 | b.Property("Id") 75 | .ValueGeneratedOnAdd() 76 | .HasColumnType("INTEGER"); 77 | 78 | b.Property("ClaimType") 79 | .HasColumnType("TEXT"); 80 | 81 | b.Property("ClaimValue") 82 | .HasColumnType("TEXT"); 83 | 84 | b.Property("UserId") 85 | .IsRequired() 86 | .HasColumnType("TEXT"); 87 | 88 | b.HasKey("Id"); 89 | 90 | b.HasIndex("UserId"); 91 | 92 | b.ToTable("AspNetUserClaims", (string)null); 93 | }); 94 | 95 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 96 | { 97 | b.Property("LoginProvider") 98 | .HasColumnType("TEXT"); 99 | 100 | b.Property("ProviderKey") 101 | .HasColumnType("TEXT"); 102 | 103 | b.Property("ProviderDisplayName") 104 | .HasColumnType("TEXT"); 105 | 106 | b.Property("UserId") 107 | .IsRequired() 108 | .HasColumnType("TEXT"); 109 | 110 | b.HasKey("LoginProvider", "ProviderKey"); 111 | 112 | b.HasIndex("UserId"); 113 | 114 | b.ToTable("AspNetUserLogins", (string)null); 115 | }); 116 | 117 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => 118 | { 119 | b.Property("UserId") 120 | .HasColumnType("TEXT"); 121 | 122 | b.Property("RoleId") 123 | .HasColumnType("TEXT"); 124 | 125 | b.HasKey("UserId", "RoleId"); 126 | 127 | b.HasIndex("RoleId"); 128 | 129 | b.ToTable("AspNetUserRoles", (string)null); 130 | }); 131 | 132 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 133 | { 134 | b.Property("UserId") 135 | .HasColumnType("TEXT"); 136 | 137 | b.Property("LoginProvider") 138 | .HasColumnType("TEXT"); 139 | 140 | b.Property("Name") 141 | .HasColumnType("TEXT"); 142 | 143 | b.Property("Value") 144 | .HasColumnType("TEXT"); 145 | 146 | b.HasKey("UserId", "LoginProvider", "Name"); 147 | 148 | b.ToTable("AspNetUserTokens", (string)null); 149 | }); 150 | 151 | modelBuilder.Entity("Todo", b => 152 | { 153 | b.Property("Id") 154 | .ValueGeneratedOnAdd() 155 | .HasColumnType("INTEGER"); 156 | 157 | b.Property("IsComplete") 158 | .HasColumnType("INTEGER"); 159 | 160 | b.Property("OwnerId") 161 | .IsRequired() 162 | .HasColumnType("TEXT"); 163 | 164 | b.Property("Title") 165 | .IsRequired() 166 | .HasColumnType("TEXT"); 167 | 168 | b.HasKey("Id"); 169 | 170 | b.HasIndex("OwnerId"); 171 | 172 | b.ToTable("Todos"); 173 | }); 174 | 175 | modelBuilder.Entity("TodoApi.TodoUser", b => 176 | { 177 | b.Property("Id") 178 | .HasColumnType("TEXT"); 179 | 180 | b.Property("AccessFailedCount") 181 | .HasColumnType("INTEGER"); 182 | 183 | b.Property("ConcurrencyStamp") 184 | .IsConcurrencyToken() 185 | .HasColumnType("TEXT"); 186 | 187 | b.Property("Email") 188 | .HasMaxLength(256) 189 | .HasColumnType("TEXT"); 190 | 191 | b.Property("EmailConfirmed") 192 | .HasColumnType("INTEGER"); 193 | 194 | b.Property("LockoutEnabled") 195 | .HasColumnType("INTEGER"); 196 | 197 | b.Property("LockoutEnd") 198 | .HasColumnType("TEXT"); 199 | 200 | b.Property("NormalizedEmail") 201 | .HasMaxLength(256) 202 | .HasColumnType("TEXT"); 203 | 204 | b.Property("NormalizedUserName") 205 | .HasMaxLength(256) 206 | .HasColumnType("TEXT"); 207 | 208 | b.Property("PasswordHash") 209 | .HasColumnType("TEXT"); 210 | 211 | b.Property("PhoneNumber") 212 | .HasColumnType("TEXT"); 213 | 214 | b.Property("PhoneNumberConfirmed") 215 | .HasColumnType("INTEGER"); 216 | 217 | b.Property("SecurityStamp") 218 | .HasColumnType("TEXT"); 219 | 220 | b.Property("TwoFactorEnabled") 221 | .HasColumnType("INTEGER"); 222 | 223 | b.Property("UserName") 224 | .HasMaxLength(256) 225 | .HasColumnType("TEXT"); 226 | 227 | b.HasKey("Id"); 228 | 229 | b.HasIndex("NormalizedEmail") 230 | .HasDatabaseName("EmailIndex"); 231 | 232 | b.HasIndex("NormalizedUserName") 233 | .IsUnique() 234 | .HasDatabaseName("UserNameIndex"); 235 | 236 | b.ToTable("AspNetUsers", (string)null); 237 | }); 238 | 239 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => 240 | { 241 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 242 | .WithMany() 243 | .HasForeignKey("RoleId") 244 | .OnDelete(DeleteBehavior.Cascade) 245 | .IsRequired(); 246 | }); 247 | 248 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 249 | { 250 | b.HasOne("TodoApi.TodoUser", null) 251 | .WithMany() 252 | .HasForeignKey("UserId") 253 | .OnDelete(DeleteBehavior.Cascade) 254 | .IsRequired(); 255 | }); 256 | 257 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 258 | { 259 | b.HasOne("TodoApi.TodoUser", null) 260 | .WithMany() 261 | .HasForeignKey("UserId") 262 | .OnDelete(DeleteBehavior.Cascade) 263 | .IsRequired(); 264 | }); 265 | 266 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => 267 | { 268 | b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) 269 | .WithMany() 270 | .HasForeignKey("RoleId") 271 | .OnDelete(DeleteBehavior.Cascade) 272 | .IsRequired(); 273 | 274 | b.HasOne("TodoApi.TodoUser", null) 275 | .WithMany() 276 | .HasForeignKey("UserId") 277 | .OnDelete(DeleteBehavior.Cascade) 278 | .IsRequired(); 279 | }); 280 | 281 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => 282 | { 283 | b.HasOne("TodoApi.TodoUser", null) 284 | .WithMany() 285 | .HasForeignKey("UserId") 286 | .OnDelete(DeleteBehavior.Cascade) 287 | .IsRequired(); 288 | }); 289 | 290 | modelBuilder.Entity("Todo", b => 291 | { 292 | b.HasOne("TodoApi.TodoUser", null) 293 | .WithMany() 294 | .HasForeignKey("OwnerId") 295 | .OnDelete(DeleteBehavior.Cascade) 296 | .IsRequired(); 297 | }); 298 | #pragma warning restore 612, 618 299 | } 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /Todo.Api/Migrations/20221123165051_RemoveIsAdmin.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.EntityFrameworkCore.Infrastructure; 5 | using Microsoft.EntityFrameworkCore.Migrations; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using TodoApi; 8 | 9 | #nullable disable 10 | 11 | namespace TodoApi.Migrations 12 | { 13 | [DbContext(typeof(TodoDbContext))] 14 | [Migration("20221123165051_RemoveIsAdmin")] 15 | partial class RemoveIsAdmin 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); 22 | 23 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => 24 | { 25 | b.Property("Id") 26 | .HasColumnType("TEXT"); 27 | 28 | b.Property("ConcurrencyStamp") 29 | .IsConcurrencyToken() 30 | .HasColumnType("TEXT"); 31 | 32 | b.Property("Name") 33 | .HasMaxLength(256) 34 | .HasColumnType("TEXT"); 35 | 36 | b.Property("NormalizedName") 37 | .HasMaxLength(256) 38 | .HasColumnType("TEXT"); 39 | 40 | b.HasKey("Id"); 41 | 42 | b.HasIndex("NormalizedName") 43 | .IsUnique() 44 | .HasDatabaseName("RoleNameIndex"); 45 | 46 | b.ToTable("AspNetRoles", (string)null); 47 | }); 48 | 49 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => 50 | { 51 | b.Property("Id") 52 | .ValueGeneratedOnAdd() 53 | .HasColumnType("INTEGER"); 54 | 55 | b.Property("ClaimType") 56 | .HasColumnType("TEXT"); 57 | 58 | b.Property("ClaimValue") 59 | .HasColumnType("TEXT"); 60 | 61 | b.Property("RoleId") 62 | .IsRequired() 63 | .HasColumnType("TEXT"); 64 | 65 | b.HasKey("Id"); 66 | 67 | b.HasIndex("RoleId"); 68 | 69 | b.ToTable("AspNetRoleClaims", (string)null); 70 | }); 71 | 72 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => 73 | { 74 | b.Property("Id") 75 | .ValueGeneratedOnAdd() 76 | .HasColumnType("INTEGER"); 77 | 78 | b.Property("ClaimType") 79 | .HasColumnType("TEXT"); 80 | 81 | b.Property("ClaimValue") 82 | .HasColumnType("TEXT"); 83 | 84 | b.Property("UserId") 85 | .IsRequired() 86 | .HasColumnType("TEXT"); 87 | 88 | b.HasKey("Id"); 89 | 90 | b.HasIndex("UserId"); 91 | 92 | b.ToTable("AspNetUserClaims", (string)null); 93 | }); 94 | 95 | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => 96 | { 97 | b.Property