diff --git a/.github/workflows/Abstract.yml b/.github/workflows/Abstract.yml deleted file mode 100644 index 77c8ded..0000000 --- a/.github/workflows/Abstract.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: .NET - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - name: Restore dependencies - run: dotnet restore - working-directory: ./MiddleWare/Autorizacion.Abstracciones - - name: Build - run: dotnet build --no-restore --configuration Release - working-directory: ./MiddleWare/Autorizacion.Abstracciones - - name: Pack - run: dotnet pack --configuration Release - working-directory: ./MiddleWare/Autorizacion.Abstracciones - - uses: actions/upload-artifact@v4 - with: - name: Autorizacion.Abstracciones - if-no-files-found: error - retention-days: 7 - path: ./MiddleWare/Autorizacion.Abstracciones/bin/Release/*.nupkg - - release: - runs-on: ubuntu-latest - needs: build - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Downlod Artifacts - uses: actions/download-artifact@v4 - - - name: List Files - run: ls -R - - - name: Prep packages - run: dotnet nuget add source --username Isma-L154 --password ${{ secrets.NUGET_TOKEN }} --store-password-in-clear-text --name Package "https://nuget.pkg.github.com/Isma-L154/index.json" - - name: Push package to GitHub packages - run: dotnet nuget push Autorizacion.Abstracciones/*.nupkg --api-key ${{ secrets.NUGET_TOKEN }} --source "Package" --skip-duplicate diff --git a/.github/workflows/Auth.yml b/.github/workflows/Auth.yml deleted file mode 100644 index 64caac9..0000000 --- a/.github/workflows/Auth.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: .NET - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - name: Restore dependencies - run: dotnet restore - working-directory: ./MiddleWare - - name: Build - run: dotnet build --no-restore --configuration Release - working-directory: ./MiddleWare - - name: Pack - run: dotnet pack --configuration Release --no-build --output ./nupkgs - working-directory: ./MiddleWare - - - uses: actions/upload-artifact@v4 - with: - name: Autorizacion.Middleware - if-no-files-found: error - retention-days: 7 - path: /home/runner/work/MiddleWare/MiddleWare/MiddleWare/*/*/*/*.nupkg - - release: - runs-on: ubuntu-latest - needs: build - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Downlod Artifacts - uses: actions/download-artifact@v4 - - - name: List Files - run: ls -R - - - name: Prep packages - run: dotnet nuget add source --username Isma-L154 --password ${{ secrets.NUGET_TOKEN }} --store-password-in-clear-text --name Package "https://nuget.pkg.github.com/Isma-L154/index.json" - - name: Push package to GitHub packages - run: dotnet nuget push Autorizacion.Middleware/*/*/*/*.nupkg --api-key ${{ secrets.NUGET_TOKEN }} --source "Package" --skip-duplicate diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9f320cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches-ignore: [] # build every branch push + pull_request: + branches: [ "main" ] + workflow_dispatch: + +# Cancel superseded runs on the same ref to save minutes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Build.props') }} + restore-keys: nuget-${{ runner.os }}- + + - name: Restore + run: dotnet restore Authorization.sln + + - name: Build + run: dotnet build Authorization.sln --no-restore --configuration Release + + - name: Test + run: dotnet test Authorization.sln --no-build --configuration Release --verbosity normal diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e372b78 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +on: + push: + branches: [ "main" ] + workflow_dispatch: + +# Use the built-in GITHUB_TOKEN to publish to GitHub Packages — +# no personal access token to manage or rotate. +permissions: + contents: read + packages: write + +concurrency: + group: release-main + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore Authorization.sln + + - name: Build + run: dotnet build Authorization.sln --no-restore --configuration Release + + - name: Test + run: dotnet test Authorization.sln --no-build --configuration Release + + - name: Pack + run: dotnet pack Authorization.sln --no-build --configuration Release --output "${{ github.workspace }}/artifacts" + + - name: Upload packages artifact + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + if-no-files-found: error + retention-days: 7 + path: ${{ github.workspace }}/artifacts/*.nupkg + + - name: Push to GitHub Packages + run: > + dotnet nuget push "${{ github.workspace }}/artifacts/*.nupkg" + --api-key ${{ secrets.GITHUB_TOKEN }} + --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" + --skip-duplicate diff --git a/.gitignore b/.gitignore index a4fe18b..01e3c21 100644 --- a/.gitignore +++ b/.gitignore @@ -398,3 +398,10 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml + +# Claude / AI assistant local files (never commit) +CLAUDE.md +.claude/ +.claude*/ +CLAUDE.local.md +**/CLAUDE.md diff --git a/Authorization.sln b/Authorization.sln new file mode 100644 index 0000000..4e844bf --- /dev/null +++ b/Authorization.sln @@ -0,0 +1,114 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.Abstractions", "src\Authorization.Abstractions\Authorization.Abstractions.csproj", "{60BA81F8-B279-47E3-8786-6FAE86C81FD3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.Common", "src\Authorization.Common\Authorization.Common.csproj", "{E8AF7B80-0A65-45E5-8F73-357A0FFFF699}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.DataAccess", "src\Authorization.DataAccess\Authorization.DataAccess.csproj", "{03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.Business", "src\Authorization.Business\Authorization.Business.csproj", "{8B921C4E-CCBD-49DE-91F8-0955695BC2D7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.Middleware", "src\Authorization.Middleware\Authorization.Middleware.csproj", "{C3F978CB-98CB-46B9-82FB-D795B1D76DA4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Authorization.UnitTests", "tests\Authorization.UnitTests\Authorization.UnitTests.csproj", "{E3F53F4A-E25E-4221-877B-81917F7C6C5A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Debug|x64.ActiveCfg = Debug|Any CPU + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Debug|x64.Build.0 = Debug|Any CPU + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Debug|x86.ActiveCfg = Debug|Any CPU + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Debug|x86.Build.0 = Debug|Any CPU + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Release|Any CPU.Build.0 = Release|Any CPU + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Release|x64.ActiveCfg = Release|Any CPU + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Release|x64.Build.0 = Release|Any CPU + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Release|x86.ActiveCfg = Release|Any CPU + {60BA81F8-B279-47E3-8786-6FAE86C81FD3}.Release|x86.Build.0 = Release|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|x64.ActiveCfg = Debug|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|x64.Build.0 = Debug|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|x86.ActiveCfg = Debug|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Debug|x86.Build.0 = Debug|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|Any CPU.Build.0 = Release|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|x64.ActiveCfg = Release|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|x64.Build.0 = Release|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|x86.ActiveCfg = Release|Any CPU + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699}.Release|x86.Build.0 = Release|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Debug|x64.ActiveCfg = Debug|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Debug|x64.Build.0 = Debug|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Debug|x86.ActiveCfg = Debug|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Debug|x86.Build.0 = Debug|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Release|Any CPU.Build.0 = Release|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Release|x64.ActiveCfg = Release|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Release|x64.Build.0 = Release|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Release|x86.ActiveCfg = Release|Any CPU + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0}.Release|x86.Build.0 = Release|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Debug|x64.ActiveCfg = Debug|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Debug|x64.Build.0 = Debug|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Debug|x86.ActiveCfg = Debug|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Debug|x86.Build.0 = Debug|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Release|Any CPU.Build.0 = Release|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Release|x64.ActiveCfg = Release|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Release|x64.Build.0 = Release|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Release|x86.ActiveCfg = Release|Any CPU + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7}.Release|x86.Build.0 = Release|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Debug|x64.Build.0 = Debug|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Debug|x86.Build.0 = Debug|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Release|Any CPU.Build.0 = Release|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Release|x64.ActiveCfg = Release|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Release|x64.Build.0 = Release|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Release|x86.ActiveCfg = Release|Any CPU + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4}.Release|x86.Build.0 = Release|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Debug|x64.ActiveCfg = Debug|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Debug|x64.Build.0 = Debug|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Debug|x86.ActiveCfg = Debug|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Debug|x86.Build.0 = Debug|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Release|Any CPU.Build.0 = Release|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Release|x64.ActiveCfg = Release|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Release|x64.Build.0 = Release|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Release|x86.ActiveCfg = Release|Any CPU + {E3F53F4A-E25E-4221-877B-81917F7C6C5A}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {60BA81F8-B279-47E3-8786-6FAE86C81FD3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E8AF7B80-0A65-45E5-8F73-357A0FFFF699} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {03B4B2E4-7A31-44B1-87B4-3A939DAD29E0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {8B921C4E-CCBD-49DE-91F8-0955695BC2D7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C3F978CB-98CB-46B9-82FB-D795B1D76DA4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E3F53F4A-E25E-4221-877B-81917F7C6C5A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal diff --git a/MiddleWare/Autorizacion.Abstracciones/Autorizacion.Abstracciones.csproj b/MiddleWare/Autorizacion.Abstracciones/Autorizacion.Abstracciones.csproj deleted file mode 100644 index 2b3967c..0000000 --- a/MiddleWare/Autorizacion.Abstracciones/Autorizacion.Abstracciones.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net8.0 - enable - enable - 1.2.0 - True - - - - - - diff --git a/MiddleWare/Autorizacion.Abstracciones/BW/IAutorizacionBW.cs b/MiddleWare/Autorizacion.Abstracciones/BW/IAutorizacionBW.cs deleted file mode 100644 index 4e4e1de..0000000 --- a/MiddleWare/Autorizacion.Abstracciones/BW/IAutorizacionBW.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Autorizacion.Abstracciones.Modelos; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Autorizacion.Abstracciones.BW -{ - public interface IAutorizacionBW - { - Task ObtenerUsuario(Usuario usuario); - Task> ObtenerPerfilesxUsuario(Usuario usuario); - } -} diff --git a/MiddleWare/Autorizacion.Abstracciones/DA/IRepositorioDapper.cs b/MiddleWare/Autorizacion.Abstracciones/DA/IRepositorioDapper.cs deleted file mode 100644 index f222673..0000000 --- a/MiddleWare/Autorizacion.Abstracciones/DA/IRepositorioDapper.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data.SqlClient; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Autorizacion.Abstracciones.DA -{ - public interface IRepositorioDapper - { - SqlConnection ObtenerRepositorioDapper(); - } -} diff --git a/MiddleWare/Autorizacion.Abstracciones/DA/ISeguridadDA.cs b/MiddleWare/Autorizacion.Abstracciones/DA/ISeguridadDA.cs deleted file mode 100644 index cc539a0..0000000 --- a/MiddleWare/Autorizacion.Abstracciones/DA/ISeguridadDA.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Autorizacion.Abstracciones.Modelos; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Autorizacion.Abstracciones.DA -{ - public interface ISeguridadDA - { - Task ObtenerUsuario(Usuario usuario); - Task> ObtenerPerfilesxUsuario(Usuario usuario); - - } -} diff --git a/MiddleWare/Autorizacion.Abstracciones/Entidades/Perfil.cs b/MiddleWare/Autorizacion.Abstracciones/Entidades/Perfil.cs deleted file mode 100644 index 0c43090..0000000 --- a/MiddleWare/Autorizacion.Abstracciones/Entidades/Perfil.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Autorizacion.Abstracciones.Entidades -{ - public class Perfil - { - public int Id { get; set; } - public string Nombre { get; set; } - } -} diff --git a/MiddleWare/Autorizacion.Abstracciones/Entidades/Usuario.cs b/MiddleWare/Autorizacion.Abstracciones/Entidades/Usuario.cs deleted file mode 100644 index e4e5a17..0000000 --- a/MiddleWare/Autorizacion.Abstracciones/Entidades/Usuario.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Autorizacion.Abstracciones.Entidades -{ - public class Usuario - { - public Guid Id { get; set; } - public string NombreUsuario { get; set; } - public string PasswordHash { get; set; } - public string CorreoElectronico { get; set; } - } -} diff --git a/MiddleWare/Autorizacion.Abstracciones/Modelos/Perfil.cs b/MiddleWare/Autorizacion.Abstracciones/Modelos/Perfil.cs deleted file mode 100644 index cd69c0f..0000000 --- a/MiddleWare/Autorizacion.Abstracciones/Modelos/Perfil.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Autorizacion.Abstracciones.Modelos -{ - public class Perfil - { - public int Id { get; set; } - public string Nombre { get; set; } - } -} diff --git a/MiddleWare/Autorizacion.Abstracciones/Modelos/Usuario.cs b/MiddleWare/Autorizacion.Abstracciones/Modelos/Usuario.cs deleted file mode 100644 index bf074df..0000000 --- a/MiddleWare/Autorizacion.Abstracciones/Modelos/Usuario.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Autorizacion.Abstracciones.Modelos -{ - public class Usuario - { - public Guid Id { get; set; } - public string NombreUsuario { get; set; } - public string PasswordHash { get; set; } - public string CorreoElectronico { get; set; } - } -} diff --git a/MiddleWare/Autorizacion.Middleware/Autorizacion.Middleware.csproj b/MiddleWare/Autorizacion.Middleware/Autorizacion.Middleware.csproj deleted file mode 100644 index b3304f0..0000000 --- a/MiddleWare/Autorizacion.Middleware/Autorizacion.Middleware.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - net8.0 - enable - enable - 1.1.0 - True - - - - - - - - - - diff --git a/MiddleWare/Autorizacion.Middleware/ClaimsPerfil.cs b/MiddleWare/Autorizacion.Middleware/ClaimsPerfil.cs deleted file mode 100644 index d0976be..0000000 --- a/MiddleWare/Autorizacion.Middleware/ClaimsPerfil.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Autorizacion.Abstracciones.BW; -using Autorizacion.Abstracciones.Modelos; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using System.Security.Claims; - -namespace Autorizacion.Middleware -{ - public class ClaimsPerfil - { - private readonly RequestDelegate _next; - private readonly IConfiguration _configuration; - private IAutorizacionBW _autorizacionBW; - - public ClaimsPerfil(RequestDelegate next, IConfiguration configuration) - { - _next = next; - _configuration = configuration; - } - //Capturamos lo que hay e intercepta el flujo - public async Task InvokeAsync(HttpContext httpContext, IAutorizacionBW autorizacionBW) - { - _autorizacionBW = autorizacionBW; - ClaimsIdentity appIdentity = await ValidarAutorizacion(httpContext); - httpContext.User.AddIdentity(appIdentity); //Se inyecta un Claim al Usuario - await _next(httpContext); // Se lo mandamos a la aplicacion para que continue - } - - private async Task ValidarAutorizacion(HttpContext httpContext) - { - var claims = new List(); - if (httpContext.User != null && httpContext.User.Identity.IsAuthenticated) //Si el usuario esta autenticado, obtenemos la info del usuario - { - await ObtenerUsuario(httpContext, claims); - await ObtenerPerfiles(httpContext, claims); - } - var appIdentity = new ClaimsIdentity(claims); //Le mandamos la info previamente recolectada - return appIdentity; - } - - //Perfiles ---- - private async Task ObtenerPerfiles(HttpContext httpContext, List claims) - { - var perfiles = await obtenerInformacionPerfiles(httpContext); - if (perfiles != null && perfiles.Any()) - { - foreach (var perfil in perfiles) - { - //Realizamos un Claim del ID/Rol que tiene - claims.Add(new Claim(ClaimTypes.Role, perfil.Id.ToString())); - } - } - } - - private async Task> obtenerInformacionPerfiles(HttpContext httpContext) - { - //Obtenemos la info del perfil por medio del flujo del BW - return await _autorizacionBW.ObtenerPerfilesxUsuario(new Abstracciones.Modelos.Usuario { NombreUsuario = httpContext.User.Claims.Where(c => c.Type == "usuario").FirstOrDefault().Value }); - } - - //Usuarios ---- - private async Task ObtenerUsuario(HttpContext httpContext, List claims) - { - var usuario = await obtenerInformacionUsuario(httpContext); - if (usuario is not null && !string.IsNullOrEmpty(usuario.Id.ToString()) && !string.IsNullOrEmpty(usuario.NombreUsuario.ToString()) && !string.IsNullOrEmpty(usuario.CorreoElectronico.ToString())) - { - //Si todo es correcto, y ninguna info del user es null, añadimos un claim por cada atributo que sea necesario - claims.Add(new Claim(ClaimTypes.Email, usuario.CorreoElectronico)); - claims.Add(new Claim(ClaimTypes.Name, usuario.NombreUsuario)); - claims.Add(new Claim("IdUsuario", usuario.Id.ToString())); - } - } - - private async Task obtenerInformacionUsuario(HttpContext httpContext) - { - //Obtenemos la info del usuario por medio del flujo del BW - return await _autorizacionBW.ObtenerUsuario(new Abstracciones.Modelos.Usuario { NombreUsuario = httpContext.User.Claims.Where(c => c.Type == "usuario").FirstOrDefault().Value }); - } - } - public static class ClaimsUsuarioMiddlewareExtensions - { - public static IApplicationBuilder AutorizacionClaims(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } - } -} diff --git a/MiddleWare/BW/Autorizacion.BW.csproj b/MiddleWare/BW/Autorizacion.BW.csproj deleted file mode 100644 index 32d7320..0000000 --- a/MiddleWare/BW/Autorizacion.BW.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - - net8.0 - enable - enable - 1.1.0 - - - - - diff --git a/MiddleWare/BW/AutorizacionBW.cs b/MiddleWare/BW/AutorizacionBW.cs deleted file mode 100644 index a171d8b..0000000 --- a/MiddleWare/BW/AutorizacionBW.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Autorizacion.Abstracciones.BW; -using Autorizacion.Abstracciones.DA; -using Autorizacion.Abstracciones.Modelos; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Autorizacion.BW -{ - public class AutorizacionBW : IAutorizacionBW - { - private ISeguridadDA _seguridadDA; - - public AutorizacionBW(ISeguridadDA seguridadDA) - { - _seguridadDA = seguridadDA; - } - public async Task> ObtenerPerfilesxUsuario(Usuario usuario) - { - return await _seguridadDA.ObtenerPerfilesxUsuario(usuario); - } - - public async Task ObtenerUsuario(Usuario usuario) - { - return await _seguridadDA.ObtenerUsuario(usuario); - } - } -} diff --git a/MiddleWare/DA/Autorizacion.DA.csproj b/MiddleWare/DA/Autorizacion.DA.csproj deleted file mode 100644 index e725811..0000000 --- a/MiddleWare/DA/Autorizacion.DA.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - net8.0 - enable - enable - 1.3.0 - - - - - - - - - - - - diff --git a/MiddleWare/DA/Repos/RepositorioDapper.cs b/MiddleWare/DA/Repos/RepositorioDapper.cs deleted file mode 100644 index 50cf6f1..0000000 --- a/MiddleWare/DA/Repos/RepositorioDapper.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Autorizacion.Abstracciones.DA; -using Microsoft.Extensions.Configuration; -using System; -using System.Collections.Generic; -using System.Data.SqlClient; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Autorizacion.DA.Repos -{ - public class RepositorioDapper : IRepositorioDapper - { - private readonly IConfiguration _configutarion; - private SqlConnection _connection; - - public RepositorioDapper(IConfiguration configutarion) - { - _configutarion = configutarion; - _connection = new SqlConnection(_configutarion.GetConnectionString("BDSeguridad")); - } - - public SqlConnection ObtenerRepositorioDapper() - { - return _connection; - } - } -} diff --git a/MiddleWare/DA/SeguridadDA.cs b/MiddleWare/DA/SeguridadDA.cs deleted file mode 100644 index 50bc1f8..0000000 --- a/MiddleWare/DA/SeguridadDA.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Autorizacion.Abstracciones.DA; -using Autorizacion.Abstracciones.Modelos; -using Dapper; -using Helpers; -using System.Data.SqlClient; - -namespace Autorizacion.DA -{ - public class SeguridadDA : ISeguridadDA - { - IRepositorioDapper _repositorioDapper; - private SqlConnection _sqlConnection; - - public SeguridadDA(IRepositorioDapper repositorioDapper) - { - _repositorioDapper = repositorioDapper; - _sqlConnection = _repositorioDapper.ObtenerRepositorioDapper(); - } - public async Task> ObtenerPerfilesxUsuario(Usuario usuario) - { - string sql = @"[ObtenerPerfilesxUsuario]"; - var consulta = await _sqlConnection.QueryAsync(sql, new { CorreoElectronico = usuario.CorreoElectronico, NombreUsuario = usuario.NombreUsuario }); - return Convertidor.ConvertirLista(consulta); - } - - public async Task ObtenerUsuario(Usuario usuario) - { - string sql = @"[ObtenerUsuario]"; - var consulta = await _sqlConnection.QueryAsync(sql, new { CorreoElectronico = usuario.CorreoElectronico, NombreUsuario = usuario.NombreUsuario }); - return Convertidor.Convertir(consulta.FirstOrDefault()); - } - } -} diff --git a/MiddleWare/Helpers/Convertidor.cs b/MiddleWare/Helpers/Convertidor.cs deleted file mode 100644 index d028925..0000000 --- a/MiddleWare/Helpers/Convertidor.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Helpers -{ - public static class Convertidor - { - public static TModeloEntrada Clonar(TModeloEntrada elemento) where TModeloEntrada : class, new() => Convertidor.Convertir(elemento); - - public static TModeloSalida Convertir( - TModeloEntrada elementoBase, - Action reglaTransformacion = null) - where TModeloEntrada : class - where TModeloSalida : new() - { - return Mapeador.MapearObjetos(elementoBase, reglaTransformacion); - } - - public static IEnumerable ConvertirLista( - IEnumerable elementos, - Action reglaTransformacion = null) - where TModeloEntrada : class - where TModeloSalida : new() - { - return (IEnumerable)elementos.Select((Func)(elementoBase => Mapeador.MapearObjetos(elementoBase, reglaTransformacion))).ToList(); - } - } -} diff --git a/MiddleWare/Helpers/Helpers.csproj b/MiddleWare/Helpers/Helpers.csproj deleted file mode 100644 index 6928042..0000000 --- a/MiddleWare/Helpers/Helpers.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - - net8.0 - enable - enable - 1.1.0 - - - - - diff --git a/MiddleWare/Helpers/Mapeador.cs b/MiddleWare/Helpers/Mapeador.cs deleted file mode 100644 index c9413fa..0000000 --- a/MiddleWare/Helpers/Mapeador.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; - -namespace Helpers -{ - public static class Mapeador - { - public static TModeloSalida MapearObjetos( - TModeloEntrada objOrigen, - Action ReglaTransformacion = null) - where TModeloEntrada : class - where TModeloSalida : new() - { - Type type1 = typeof(TModeloEntrada); - Type type2 = typeof(TModeloSalida); - TModeloSalida destino = default(TModeloSalida); - if ((object)objOrigen != null) - { - destino = (TModeloSalida)Activator.CreateInstance(type2); - PropertyInfo[] properties1 = type1.GetProperties(); - PropertyInfo[] properties2 = type2.GetProperties(); - Mapeador.SincronizarObjetos(objOrigen, destino, properties1, properties2); - if (ReglaTransformacion != null) - ReglaTransformacion(objOrigen, destino); - } - return destino; - } - - private static void SincronizarObjetos( - TModeloEntrada origen, - TModeloSalida destino, - PropertyInfo[] propiedadesOrigen, - PropertyInfo[] propiedadesDestino) - { - foreach (PropertyInfo propertyInfo1 in propiedadesOrigen) - { - string nombrePropiedad = propertyInfo1.Name; - PropertyInfo propertyInfo2 = ((IEnumerable)propiedadesDestino).FirstOrDefault((Func)(x => x.Name == nombrePropiedad)); - if (propertyInfo2 != (PropertyInfo)null && propertyInfo2.CanWrite && propertyInfo2.GetIndexParameters().Length == 0 && propertyInfo1.PropertyType.Name == propertyInfo2.PropertyType.Name) - { - if ((propertyInfo2.PropertyType.IsClass ? 1 : (propertyInfo2.PropertyType.IsInterface ? 1 : 0)) == 0 | propertyInfo2.PropertyType.Name.Equals("String") | propertyInfo2.PropertyType.Name.EndsWith("[]")) - { - object obj = propertyInfo1.GetValue((object)origen, (object[])null); - propertyInfo2.SetValue((object)destino, obj, (object[])null); - } - } - } - } - } -} diff --git a/MiddleWare/MiddleWare.sln b/MiddleWare/MiddleWare.sln deleted file mode 100644 index 6fbc6be..0000000 --- a/MiddleWare/MiddleWare.sln +++ /dev/null @@ -1,49 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.8.34525.116 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Autorizacion.Abstracciones", "Autorizacion.Abstracciones\Autorizacion.Abstracciones.csproj", "{F84E1C30-E36A-4809-989C-3BD1268EFD93}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Helpers", "Helpers\Helpers.csproj", "{270F74FB-FB5B-4472-9FC1-A554BBA780BD}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Autorizacion.DA", "DA\Autorizacion.DA.csproj", "{FDA5784D-8BC8-448E-A04E-D4E1C7716240}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Autorizacion.BW", "BW\Autorizacion.BW.csproj", "{A624FB44-349D-4E11-AA7F-B3EB69161494}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Autorizacion.Middleware", "Autorizacion.Middleware\Autorizacion.Middleware.csproj", "{1F0C08C4-F365-452E-A180-947C28C18DEA}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {F84E1C30-E36A-4809-989C-3BD1268EFD93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F84E1C30-E36A-4809-989C-3BD1268EFD93}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F84E1C30-E36A-4809-989C-3BD1268EFD93}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F84E1C30-E36A-4809-989C-3BD1268EFD93}.Release|Any CPU.Build.0 = Release|Any CPU - {270F74FB-FB5B-4472-9FC1-A554BBA780BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {270F74FB-FB5B-4472-9FC1-A554BBA780BD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {270F74FB-FB5B-4472-9FC1-A554BBA780BD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {270F74FB-FB5B-4472-9FC1-A554BBA780BD}.Release|Any CPU.Build.0 = Release|Any CPU - {FDA5784D-8BC8-448E-A04E-D4E1C7716240}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FDA5784D-8BC8-448E-A04E-D4E1C7716240}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FDA5784D-8BC8-448E-A04E-D4E1C7716240}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FDA5784D-8BC8-448E-A04E-D4E1C7716240}.Release|Any CPU.Build.0 = Release|Any CPU - {A624FB44-349D-4E11-AA7F-B3EB69161494}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A624FB44-349D-4E11-AA7F-B3EB69161494}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A624FB44-349D-4E11-AA7F-B3EB69161494}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A624FB44-349D-4E11-AA7F-B3EB69161494}.Release|Any CPU.Build.0 = Release|Any CPU - {1F0C08C4-F365-452E-A180-947C28C18DEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1F0C08C4-F365-452E-A180-947C28C18DEA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1F0C08C4-F365-452E-A180-947C28C18DEA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1F0C08C4-F365-452E-A180-947C28C18DEA}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {6FA99B5F-1890-48FB-9F37-068EFA98EDDE} - EndGlobalSection -EndGlobal diff --git a/README.md b/README.md index 0cc0e12..7b52be0 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,139 @@ -# 🔐 JWT Authentication & Authorization Middleware +# 🔐 JWT Authorization Claims Middleware -This side project is a simple yet effective **middleware** component designed to handle **user authentication and authorization using JSON Web Tokens (JWT)**. It is designed to be easily integrated into other projects by deploying it as a **NuGet package**. +[![CI](https://github.com/Isma-L154/MiddleWare/actions/workflows/ci.yml/badge.svg)](https://github.com/Isma-L154/MiddleWare/actions/workflows/ci.yml) +[![.NET](https://img.shields.io/badge/.NET-8.0-512BD4)](https://dotnet.microsoft.com/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -Middleware plays a vital role in web applications by acting as a filter between the HTTP request and the core application logic. This middleware ensures that only authenticated and authorized users can access protected routes or resources. +An ASP.NET Core middleware that **enriches an already-authenticated request principal** with identity claims (the user's id, name and email) and one **role claim per profile**, resolved from a SQL Server security store. It's shipped as a small set of **NuGet packages** so it can be dropped into any .NET 8 web app. + +> This is a personal side project. It sits between the HTTP request and your application logic, turning a bare authenticated token into a fully-populated `ClaimsPrincipal` your authorization policies can rely on. + +--- + +## ✨ What it does + +Once a request has been authenticated (by JWT bearer auth, for example), the middleware: + +1. Reads the configured user-name claim from the incoming principal. +2. Looks up the matching user in the security database (via a stored procedure). +3. Adds `Email`, `Name` and `IdUsuario` claims. +4. Looks up the user's profiles and adds a `Role` claim for each one. + +If anything goes wrong resolving that data (missing claim, unknown user, database outage), the request **degrades gracefully**: it continues unenriched instead of crashing the pipeline. + +--- + +## 🧱 Architecture + +The solution is layered so each concern is isolated and independently testable: + +| Project | Responsibility | +| --- | --- | +| `Authorization.Abstractions` | Contracts: entities, models, options and interfaces. No external dependencies. | +| `Authorization.Common` | Cached, reflection-based object mapper used to map entities → models. | +| `Authorization.DataAccess` | Dapper + `Microsoft.Data.SqlClient` access to stored procedures. | +| `Authorization.Business` | Thin business layer orchestrating identity resolution. | +| `Authorization.Middleware` | The ASP.NET Core middleware plus DI and pipeline extensions. | + +``` +Request ─▶ Authentication ─▶ ClaimsEnrichmentMiddleware ─▶ your app + │ + IAuthorizationManager (Business) + │ + ISecurityRepository (DataAccess) + │ + IDbConnectionFactory ─▶ SQL Server +``` --- -## 🧩 What is Middleware? +## 📦 Installation -A **middleware** is a function or component that processes HTTP requests and/or responses within the application pipeline. It's typically used to: +The packages are published to **GitHub Packages**. Add the feed and install the entry-point package (it pulls the rest in transitively): -- Authenticate and authorize users -- Log incoming and outgoing traffic -- Handle errors or exceptions -- Modify requests or responses -- Manage headers, sessions, or CORS +```bash +dotnet nuget add source "https://nuget.pkg.github.com/Isma-L154/index.json" \ + --name github --username --password + +dotnet add package Authorization.Middleware +``` --- -## ✅ Purpose of This Middleware +## 🚀 Usage + +**1. Register the services** (wires the connection factory, repository and business manager): + +```csharp +using Authorization.Middleware; + +builder.Services.AddAuthorizationClaims(); +``` -The main purpose of this middleware is to provide **basic but secure access control** through JWT-based authentication. Specifically, it: +**2. Add the middleware to the pipeline**, after authentication: -- 🔐 **Authenticates**: Verifies the validity of a JWT sent in the request (usually in the Authorization header). -- 🔓 **Authorizes**: Checks whether the user has the required role or permissions to access a specific route or resource. +```csharp +app.UseAuthentication(); +app.UseAuthorizationClaims(); // enrich the principal +app.UseAuthorization(); +``` + +**3. Configure the connection string** in `appsettings.json`: + +```json +{ + "ConnectionStrings": { + "SecurityDb": "Server=...;Database=...;Trusted_Connection=True;Encrypt=True;" + } +} +``` --- -## 🔧 How It Works +## ⚙️ Configuration + +Everything that used to be hard-coded is now configurable through `ClaimsEnrichmentOptions`: + +```csharp +builder.Services.AddAuthorizationClaims(options => +{ + options.ConnectionStringName = "SecurityDb"; // ConnectionStrings key + options.UserNameClaimType = "usuario"; // inbound JWT claim to read + options.GetUserProcedure = "ObtenerUsuario"; // stored procedure names + options.GetProfilesProcedure = "ObtenerPerfilesxUsuario"; +}); +``` + +| Option | Default | Description | +| --- | --- | --- | +| `ConnectionStringName` | `SecurityDb` | Key under `ConnectionStrings` for the security DB. | +| `UserNameClaimType` | `usuario` | Inbound claim type carrying the user name. | +| `GetUserProcedure` | `ObtenerUsuario` | Stored procedure returning a user by name/email. | +| `GetProfilesProcedure` | `ObtenerPerfilesxUsuario` | Stored procedure returning a user's profiles. | + +--- + +## 🧪 Building & testing + +```bash +dotnet build Authorization.sln -c Release +dotnet test Authorization.sln -c Release +``` -1. The client sends a request with a JWT in the `Authorization` header (e.g., `Bearer `). -2. The middleware: - - Verifies the token's validity and signature. - - Decodes the token to extract user data (e.g., ID, roles). - - Checks if the user has permission to access the endpoint. -3. If the token is invalid or permissions are insufficient, the request is rejected with the appropriate HTTP status (`401 Unauthorized` or `403 Forbidden`). +CI runs on every push and pull request; packages are published from the `main` branch. --- -## 🛠️ Technologies Used +## 🛠️ Technologies -- **JSON Web Tokens (JWT)** for token generation and validation -- Written in **[C# , .NET 8.0]** -- Lightweight, reusable, and easily integrated into any route-based app -- Deployed as a **NuGet package** for easy integration into other projects +- **.NET 8.0**, C# latest +- **Dapper** for micro-ORM data access +- **Microsoft.Data.SqlClient** (the maintained SQL Server driver) +- **xUnit** + **Moq** for unit tests +- Distributed as **NuGet packages** via GitHub Packages --- -## 📦 Deployment via NuGet +## 📄 License -This middleware is packaged and deployed as a **NuGet package**, making it easy to integrate into any **C# .NET Core** project. To install the middleware package in your project: +Released under the [MIT License](LICENSE). diff --git a/src/Authorization.Abstractions/Authorization.Abstractions.csproj b/src/Authorization.Abstractions/Authorization.Abstractions.csproj new file mode 100644 index 0000000..acdd200 --- /dev/null +++ b/src/Authorization.Abstractions/Authorization.Abstractions.csproj @@ -0,0 +1,8 @@ + + + + Authorization.Abstractions + Contracts (entities, models, options and interfaces) for the JWT authorization middleware. Has no external dependencies. + + + diff --git a/src/Authorization.Abstractions/Business/IAuthorizationManager.cs b/src/Authorization.Abstractions/Business/IAuthorizationManager.cs new file mode 100644 index 0000000..1f6751a --- /dev/null +++ b/src/Authorization.Abstractions/Business/IAuthorizationManager.cs @@ -0,0 +1,26 @@ +using Authorization.Abstractions.Models; + +namespace Authorization.Abstractions.Business; + +/// +/// Business-layer entry point for resolving the identity data used to enrich +/// the request principal (the current user and the profiles they hold). +/// +public interface IAuthorizationManager +{ + /// + /// Resolves the full user record for the supplied lookup criteria. + /// + /// Partial user carrying at least a lookup key (user name or email). + /// Token used to cancel the underlying I/O. + /// The resolved user, or null when no match exists. + Task GetUserAsync(User user, CancellationToken cancellationToken = default); + + /// + /// Resolves every profile (role) granted to the supplied user. + /// + /// User whose profiles are requested. + /// Token used to cancel the underlying I/O. + /// The profiles for the user; an empty sequence when none exist. + Task> GetProfilesForUserAsync(User user, CancellationToken cancellationToken = default); +} diff --git a/src/Authorization.Abstractions/DataAccess/IDbConnectionFactory.cs b/src/Authorization.Abstractions/DataAccess/IDbConnectionFactory.cs new file mode 100644 index 0000000..4594dff --- /dev/null +++ b/src/Authorization.Abstractions/DataAccess/IDbConnectionFactory.cs @@ -0,0 +1,20 @@ +using System.Data.Common; + +namespace Authorization.Abstractions.DataAccess; + +/// +/// Creates database connections on demand. +/// +/// +/// Every call returns a brand-new, unopened connection. This is deliberate: +/// a single is not thread-safe, so sharing one +/// instance across concurrent requests corrupts state. Handing out fresh +/// connections lets the underlying ADO.NET connection pool do its job. +/// The abstract return type keeps this contract +/// free of any concrete database-provider dependency. +/// +public interface IDbConnectionFactory +{ + /// Creates a new, unopened database connection. + DbConnection CreateConnection(); +} diff --git a/src/Authorization.Abstractions/DataAccess/ISecurityRepository.cs b/src/Authorization.Abstractions/DataAccess/ISecurityRepository.cs new file mode 100644 index 0000000..1801b3f --- /dev/null +++ b/src/Authorization.Abstractions/DataAccess/ISecurityRepository.cs @@ -0,0 +1,15 @@ +using Authorization.Abstractions.Models; + +namespace Authorization.Abstractions.DataAccess; + +/// +/// Data-access contract for the security store that backs authorization. +/// +public interface ISecurityRepository +{ + /// Reads a single user matching the supplied lookup criteria. + Task GetUserAsync(User user, CancellationToken cancellationToken = default); + + /// Reads every profile (role) granted to the supplied user. + Task> GetProfilesForUserAsync(User user, CancellationToken cancellationToken = default); +} diff --git a/src/Authorization.Abstractions/Entities/Profile.cs b/src/Authorization.Abstractions/Entities/Profile.cs new file mode 100644 index 0000000..b966ead --- /dev/null +++ b/src/Authorization.Abstractions/Entities/Profile.cs @@ -0,0 +1,13 @@ +namespace Authorization.Abstractions.Entities; + +/// +/// Database-facing representation of a security profile (role) a user can hold. +/// +public sealed class Profile +{ + /// Unique identifier of the profile. + public int Id { get; set; } + + /// Display name of the profile. + public string? Name { get; set; } +} diff --git a/src/Authorization.Abstractions/Entities/User.cs b/src/Authorization.Abstractions/Entities/User.cs new file mode 100644 index 0000000..d6ab7f9 --- /dev/null +++ b/src/Authorization.Abstractions/Entities/User.cs @@ -0,0 +1,19 @@ +namespace Authorization.Abstractions.Entities; + +/// +/// Database-facing representation of a user, as returned by the security data store. +/// +public sealed class User +{ + /// Unique identifier of the user. + public Guid Id { get; set; } + + /// Login name of the user. + public string? UserName { get; set; } + + /// Hashed password. Never expose this outside the data layer. + public string? PasswordHash { get; set; } + + /// Email address of the user. + public string? Email { get; set; } +} diff --git a/src/Authorization.Abstractions/Models/Profile.cs b/src/Authorization.Abstractions/Models/Profile.cs new file mode 100644 index 0000000..3629a98 --- /dev/null +++ b/src/Authorization.Abstractions/Models/Profile.cs @@ -0,0 +1,13 @@ +namespace Authorization.Abstractions.Models; + +/// +/// Domain model of a security profile (role) held by a user. +/// +public sealed class Profile +{ + /// Unique identifier of the profile. + public int Id { get; set; } + + /// Display name of the profile. + public string? Name { get; set; } +} diff --git a/src/Authorization.Abstractions/Models/User.cs b/src/Authorization.Abstractions/Models/User.cs new file mode 100644 index 0000000..4cb82ea --- /dev/null +++ b/src/Authorization.Abstractions/Models/User.cs @@ -0,0 +1,21 @@ +namespace Authorization.Abstractions.Models; + +/// +/// Domain model of a user, used by the business and middleware layers. +/// Kept separate from the database entity so persistence changes never leak +/// into the pipeline contract. +/// +public sealed class User +{ + /// Unique identifier of the user. + public Guid Id { get; set; } + + /// Login name of the user. + public string? UserName { get; set; } + + /// Hashed password. Never surfaced in claims, logs or responses. + public string? PasswordHash { get; set; } + + /// Email address of the user. + public string? Email { get; set; } +} diff --git a/src/Authorization.Abstractions/Options/ClaimsEnrichmentOptions.cs b/src/Authorization.Abstractions/Options/ClaimsEnrichmentOptions.cs new file mode 100644 index 0000000..a8f2515 --- /dev/null +++ b/src/Authorization.Abstractions/Options/ClaimsEnrichmentOptions.cs @@ -0,0 +1,34 @@ +namespace Authorization.Abstractions.Options; + +/// +/// Configuration for the claims-enrichment middleware and the security store. +/// Everything that used to be hard-coded (the inbound claim to read the user +/// name from, and the stored-procedure names) is configurable here so the +/// package can be reused without recompiling. +/// +public sealed class ClaimsEnrichmentOptions +{ + /// + /// Name of the connection string (in ConnectionStrings) pointing at + /// the security database. Defaults to SecurityDb. + /// + public string ConnectionStringName { get; set; } = "SecurityDb"; + + /// + /// The inbound JWT claim type that carries the user name used to look the + /// user up. Defaults to usuario to preserve the historical contract. + /// + public string UserNameClaimType { get; set; } = "usuario"; + + /// + /// Stored procedure that returns a single user by user name / email. + /// Defaults to ObtenerUsuario (the existing database object name). + /// + public string GetUserProcedure { get; set; } = "ObtenerUsuario"; + + /// + /// Stored procedure that returns the profiles for a user. + /// Defaults to ObtenerPerfilesxUsuario (the existing database object name). + /// + public string GetProfilesProcedure { get; set; } = "ObtenerPerfilesxUsuario"; +} diff --git a/src/Authorization.Business/Authorization.Business.csproj b/src/Authorization.Business/Authorization.Business.csproj new file mode 100644 index 0000000..90e68a7 --- /dev/null +++ b/src/Authorization.Business/Authorization.Business.csproj @@ -0,0 +1,12 @@ + + + + Authorization.Business + Business layer for the JWT authorization middleware; orchestrates identity resolution over the data-access layer. + + + + + + + diff --git a/src/Authorization.Business/AuthorizationManager.cs b/src/Authorization.Business/AuthorizationManager.cs new file mode 100644 index 0000000..9036357 --- /dev/null +++ b/src/Authorization.Business/AuthorizationManager.cs @@ -0,0 +1,29 @@ +using Authorization.Abstractions.Business; +using Authorization.Abstractions.DataAccess; +using Authorization.Abstractions.Models; + +namespace Authorization.Business; + +/// +/// Default that delegates identity +/// resolution to the security data-access layer. It is intentionally thin: +/// its role is to keep the middleware decoupled from persistence so business +/// rules can grow here without touching the pipeline. +/// +public sealed class AuthorizationManager : IAuthorizationManager +{ + private readonly ISecurityRepository _securityRepository; + + public AuthorizationManager(ISecurityRepository securityRepository) + { + _securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository)); + } + + /// + public Task GetUserAsync(User user, CancellationToken cancellationToken = default) + => _securityRepository.GetUserAsync(user, cancellationToken); + + /// + public Task> GetProfilesForUserAsync(User user, CancellationToken cancellationToken = default) + => _securityRepository.GetProfilesForUserAsync(user, cancellationToken); +} diff --git a/src/Authorization.Common/Authorization.Common.csproj b/src/Authorization.Common/Authorization.Common.csproj new file mode 100644 index 0000000..fc3d216 --- /dev/null +++ b/src/Authorization.Common/Authorization.Common.csproj @@ -0,0 +1,8 @@ + + + + Authorization.Common + Shared helpers for the JWT authorization middleware, including a cached reflection-based object mapper. + + + diff --git a/src/Authorization.Common/Converter.cs b/src/Authorization.Common/Converter.cs new file mode 100644 index 0000000..2435761 --- /dev/null +++ b/src/Authorization.Common/Converter.cs @@ -0,0 +1,46 @@ +namespace Authorization.Common; + +/// +/// Convenience facade over for single objects and sequences. +/// +public static class Converter +{ + /// Creates a shallow copy of . + public static TModel? Clone(TModel? source) + where TModel : class, new() + => Mapper.Map(source); + + /// Converts a single object to . + public static TDestination? Convert( + TSource? source, + Action? transform = null) + where TSource : class + where TDestination : new() + => Mapper.Map(source, transform); + + /// + /// Converts a sequence, skipping any elements that map to null. + /// Materialised to a list so the (already-executed) source query is only + /// enumerated once. + /// + public static IReadOnlyList ConvertList( + IEnumerable source, + Action? transform = null) + where TSource : class + where TDestination : new() + { + ArgumentNullException.ThrowIfNull(source); + + var result = new List(); + foreach (var element in source) + { + var mapped = Mapper.Map(element, transform); + if (mapped is not null) + { + result.Add(mapped); + } + } + + return result; + } +} diff --git a/src/Authorization.Common/Mapper.cs b/src/Authorization.Common/Mapper.cs new file mode 100644 index 0000000..0784255 --- /dev/null +++ b/src/Authorization.Common/Mapper.cs @@ -0,0 +1,87 @@ +using System.Collections.Concurrent; +using System.Reflection; + +namespace Authorization.Common; + +/// +/// Lightweight convention-based object mapper: copies matching public +/// properties (by name and type) from a source object to a new destination +/// instance. +/// +/// +/// This runs on the request hot path, so the reflection work (discovering +/// which source/destination properties line up) is computed once per +/// type-pair and cached. Only value types, strings and arrays are copied, +/// mirroring the original shallow-copy behaviour so nested reference graphs +/// are never shared by accident. +/// +public static class Mapper +{ + private static readonly ConcurrentDictionary<(Type Source, Type Destination), PropertyPair[]> PropertyMapCache = new(); + + private readonly record struct PropertyPair(PropertyInfo Source, PropertyInfo Destination); + + /// + /// Maps onto a new instance of + /// . + /// + /// Object to read values from. May be null. + /// Optional hook to apply custom rules after the automatic copy. + /// The populated destination, or the type default when is null. + public static TDestination? Map( + TSource? source, + Action? transform = null) + where TSource : class + where TDestination : new() + { + if (source is null) + { + return default; + } + + var destination = new TDestination(); + foreach (var pair in GetPropertyMap(typeof(TSource), typeof(TDestination))) + { + pair.Destination.SetValue(destination, pair.Source.GetValue(source)); + } + + transform?.Invoke(source, destination); + return destination; + } + + private static PropertyPair[] GetPropertyMap(Type source, Type destination) => + PropertyMapCache.GetOrAdd((source, destination), static key => BuildPropertyMap(key.Source, key.Destination)); + + private static PropertyPair[] BuildPropertyMap(Type source, Type destination) + { + var destinationProperties = destination + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .ToDictionary(p => p.Name, StringComparer.Ordinal); + + var pairs = new List(); + foreach (var sourceProperty in source.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!destinationProperties.TryGetValue(sourceProperty.Name, out var destinationProperty)) + { + continue; + } + + if (!destinationProperty.CanWrite || + destinationProperty.GetIndexParameters().Length != 0 || + destinationProperty.PropertyType != sourceProperty.PropertyType || + !IsCopyable(destinationProperty.PropertyType)) + { + continue; + } + + pairs.Add(new PropertyPair(sourceProperty, destinationProperty)); + } + + return pairs.ToArray(); + } + + // Copy value types, strings and arrays only; skip complex reference types + // to avoid sharing mutable nested objects between source and destination. + private static bool IsCopyable(Type type) => + !type.IsClass || type == typeof(string) || type.IsArray; +} diff --git a/src/Authorization.DataAccess/Authorization.DataAccess.csproj b/src/Authorization.DataAccess/Authorization.DataAccess.csproj new file mode 100644 index 0000000..b3485c2 --- /dev/null +++ b/src/Authorization.DataAccess/Authorization.DataAccess.csproj @@ -0,0 +1,20 @@ + + + + Authorization.DataAccess + Dapper-based data access for the JWT authorization middleware, backed by SQL Server stored procedures. + + + + + + + + + + + + + + + diff --git a/src/Authorization.DataAccess/SecurityRepository.cs b/src/Authorization.DataAccess/SecurityRepository.cs new file mode 100644 index 0000000..a624d76 --- /dev/null +++ b/src/Authorization.DataAccess/SecurityRepository.cs @@ -0,0 +1,63 @@ +using System.Data; +using Authorization.Abstractions.DataAccess; +using Authorization.Abstractions.Options; +using Authorization.Common; +using Dapper; +using Microsoft.Extensions.Options; +using Entities = Authorization.Abstractions.Entities; +using Models = Authorization.Abstractions.Models; + +namespace Authorization.DataAccess; + +/// +/// Reads users and their profiles from the security database through stored +/// procedures, using Dapper. +/// +public sealed class SecurityRepository : ISecurityRepository +{ + private readonly IDbConnectionFactory _connectionFactory; + private readonly ClaimsEnrichmentOptions _options; + + public SecurityRepository(IDbConnectionFactory connectionFactory, IOptions options) + { + _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); + _options = (options ?? throw new ArgumentNullException(nameof(options))).Value; + } + + /// + public async Task GetUserAsync(Models.User user, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(user); + + // A fresh connection per call: SqlConnection is not thread-safe and + // must not be shared across concurrent requests. `await using` guarantees + // it is returned to the pool even if the query throws. + await using var connection = _connectionFactory.CreateConnection(); + + var command = new CommandDefinition( + _options.GetUserProcedure, + new { user.Email, user.UserName }, + commandType: CommandType.StoredProcedure, + cancellationToken: cancellationToken); + + var entity = await connection.QueryFirstOrDefaultAsync(command); + return Converter.Convert(entity); + } + + /// + public async Task> GetProfilesForUserAsync(Models.User user, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(user); + + await using var connection = _connectionFactory.CreateConnection(); + + var command = new CommandDefinition( + _options.GetProfilesProcedure, + new { user.Email, user.UserName }, + commandType: CommandType.StoredProcedure, + cancellationToken: cancellationToken); + + var entities = await connection.QueryAsync(command); + return Converter.ConvertList(entities); + } +} diff --git a/src/Authorization.DataAccess/SqlConnectionFactory.cs b/src/Authorization.DataAccess/SqlConnectionFactory.cs new file mode 100644 index 0000000..763c442 --- /dev/null +++ b/src/Authorization.DataAccess/SqlConnectionFactory.cs @@ -0,0 +1,36 @@ +using System.Data.Common; +using Authorization.Abstractions.DataAccess; +using Authorization.Abstractions.Options; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Authorization.DataAccess; + +/// +/// SQL Server implementation of . +/// +/// +/// The connection string is read once and cached; each call returns a fresh +/// so callers can safely open, use and dispose it +/// without any cross-request sharing. ADO.NET pools the physical connections +/// underneath, so this is both correct and cheap. +/// +public sealed class SqlConnectionFactory : IDbConnectionFactory +{ + private readonly string _connectionString; + + public SqlConnectionFactory(IConfiguration configuration, IOptions options) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(options); + + var name = options.Value.ConnectionStringName; + _connectionString = configuration.GetConnectionString(name) + ?? throw new InvalidOperationException( + $"Connection string '{name}' was not found. Configure it under \"ConnectionStrings\"."); + } + + /// + public DbConnection CreateConnection() => new SqlConnection(_connectionString); +} diff --git a/src/Authorization.Middleware/Authorization.Middleware.csproj b/src/Authorization.Middleware/Authorization.Middleware.csproj new file mode 100644 index 0000000..e744ddd --- /dev/null +++ b/src/Authorization.Middleware/Authorization.Middleware.csproj @@ -0,0 +1,23 @@ + + + + Authorization.Middleware + ASP.NET Core middleware that enriches the authenticated principal with user and profile (role) claims resolved from a security store. + + + + + + + + + + + + + + + + + + diff --git a/src/Authorization.Middleware/AuthorizationMiddlewareExtensions.cs b/src/Authorization.Middleware/AuthorizationMiddlewareExtensions.cs new file mode 100644 index 0000000..beede5e --- /dev/null +++ b/src/Authorization.Middleware/AuthorizationMiddlewareExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Builder; + +namespace Authorization.Middleware; + +/// +/// Pipeline registration for . +/// +public static class AuthorizationMiddlewareExtensions +{ + /// + /// Adds the claims-enrichment middleware to the request pipeline. Place it + /// after authentication so the principal is already established. + /// + public static IApplicationBuilder UseAuthorizationClaims(this IApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.UseMiddleware(); + } +} diff --git a/src/Authorization.Middleware/ClaimsEnrichmentMiddleware.cs b/src/Authorization.Middleware/ClaimsEnrichmentMiddleware.cs new file mode 100644 index 0000000..cb42eef --- /dev/null +++ b/src/Authorization.Middleware/ClaimsEnrichmentMiddleware.cs @@ -0,0 +1,132 @@ +using System.Security.Claims; +using Authorization.Abstractions.Business; +using Authorization.Abstractions.Models; +using Authorization.Abstractions.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Authorization.Middleware; + +/// +/// Enriches the authenticated principal with claims resolved from the security +/// store: the user's identifier, name and email, plus a role claim per profile. +/// +/// +/// Middleware is instantiated once (singleton), so no per-request state is kept +/// in fields — the scoped is received through +/// instead. Any failure while resolving identity data +/// is logged and swallowed so a transient store outage degrades gracefully +/// (the request continues unenriched) rather than crashing the pipeline. +/// +public sealed partial class ClaimsEnrichmentMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly ClaimsEnrichmentOptions _options; + + public ClaimsEnrichmentMiddleware( + RequestDelegate next, + ILogger logger, + IOptions options) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = (options ?? throw new ArgumentNullException(nameof(options))).Value; + } + + /// Intercepts the request, enriches the principal, then forwards it. + public async Task InvokeAsync(HttpContext context, IAuthorizationManager authorizationManager) + { + var identity = await BuildClaimsIdentityAsync(context, authorizationManager); + if (identity is not null) + { + context.User.AddIdentity(identity); + } + + await _next(context); + } + + private async Task BuildClaimsIdentityAsync(HttpContext context, IAuthorizationManager authorizationManager) + { + // Only enrich real, authenticated principals; anonymous traffic flows through untouched. + if (context.User.Identity is not { IsAuthenticated: true }) + { + return null; + } + + var userName = context.User.FindFirst(_options.UserNameClaimType)?.Value; + if (string.IsNullOrWhiteSpace(userName)) + { + LogMissingUserNameClaim(_options.UserNameClaimType, context.TraceIdentifier); + return null; + } + + try + { + var claims = new List(); + var user = await authorizationManager.GetUserAsync(new User { UserName = userName }, context.RequestAborted); + if (user is null) + { + LogUserNotFound(context.TraceIdentifier); + return null; + } + + AddUserClaims(claims, user); + await AddProfileClaimsAsync(claims, user, authorizationManager, context.RequestAborted); + + return new ClaimsIdentity(claims); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + // Client went away; nothing to log at error level. + return null; + } + catch (Exception ex) + { + // Graceful degradation: never let an identity-store failure crash the request. + LogEnrichmentFailed(ex, context.TraceIdentifier); + return null; + } + } + + private static void AddUserClaims(ICollection claims, User user) + { + if (!string.IsNullOrEmpty(user.Email)) + { + claims.Add(new Claim(ClaimTypes.Email, user.Email)); + } + + if (!string.IsNullOrEmpty(user.UserName)) + { + claims.Add(new Claim(ClaimTypes.Name, user.UserName)); + } + + claims.Add(new Claim("IdUsuario", user.Id.ToString())); + } + + private async Task AddProfileClaimsAsync( + ICollection claims, + User user, + IAuthorizationManager authorizationManager, + CancellationToken cancellationToken) + { + var profiles = await authorizationManager.GetProfilesForUserAsync(user, cancellationToken); + foreach (var profile in profiles) + { + claims.Add(new Claim(ClaimTypes.Role, profile.Id.ToString())); + } + } + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Skipping claims enrichment: inbound principal has no '{ClaimType}' claim. TraceId={TraceId}")] + private partial void LogMissingUserNameClaim(string claimType, string traceId); + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Skipping claims enrichment: no user matched the inbound claim. TraceId={TraceId}")] + private partial void LogUserNotFound(string traceId); + + [LoggerMessage(Level = LogLevel.Error, + Message = "Claims enrichment failed; continuing without enriched claims. TraceId={TraceId}")] + private partial void LogEnrichmentFailed(Exception exception, string traceId); +} diff --git a/src/Authorization.Middleware/ServiceCollectionExtensions.cs b/src/Authorization.Middleware/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..042c11e --- /dev/null +++ b/src/Authorization.Middleware/ServiceCollectionExtensions.cs @@ -0,0 +1,42 @@ +using Authorization.Abstractions.Business; +using Authorization.Abstractions.DataAccess; +using Authorization.Abstractions.Options; +using Authorization.Business; +using Authorization.DataAccess; +using Microsoft.Extensions.DependencyInjection; + +namespace Authorization.Middleware; + +/// +/// Dependency-injection registration for the authorization stack. A single +/// call wires the connection factory, repository and business manager so +/// consumers no longer have to assemble the graph by hand. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers everything the claims-enrichment middleware needs. + /// + /// The service collection. + /// Optional hook to override connection-string name, claim type or stored-procedure names. + public static IServiceCollection AddAuthorizationClaims( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var optionsBuilder = services.AddOptions(); + if (configure is not null) + { + optionsBuilder.Configure(configure); + } + + // The factory only caches an immutable connection string, so it is safe + // as a singleton; it still hands out a fresh connection per call. + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..8d0c512 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,41 @@ + + + + + net8.0 + latest + enable + enable + true + true + + $(NoWarn);CS1591 + + + + + 2.0.0 + Isma-L154 + Isma-L154 + JWT Authorization Middleware + MIT + https://github.com/Isma-L154/MiddleWare + https://github.com/Isma-L154/MiddleWare + git + middleware;jwt;authentication;authorization;aspnetcore;claims + README.md + true + snupkg + + false + + + + + + + diff --git a/tests/Authorization.UnitTests/Authorization.UnitTests.csproj b/tests/Authorization.UnitTests/Authorization.UnitTests.csproj new file mode 100644 index 0000000..dc14bcf --- /dev/null +++ b/tests/Authorization.UnitTests/Authorization.UnitTests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tests/Authorization.UnitTests/AuthorizationManagerTests.cs b/tests/Authorization.UnitTests/AuthorizationManagerTests.cs new file mode 100644 index 0000000..dd7fd74 --- /dev/null +++ b/tests/Authorization.UnitTests/AuthorizationManagerTests.cs @@ -0,0 +1,48 @@ +using Authorization.Abstractions.DataAccess; +using Authorization.Abstractions.Models; +using Authorization.Business; +using Moq; + +namespace Authorization.UnitTests; + +public class AuthorizationManagerTests +{ + [Fact] + public async Task GetUserAsync_DelegatesToRepository() + { + var expected = new User { Id = Guid.NewGuid(), UserName = "jdoe" }; + var repository = new Mock(); + repository + .Setup(r => r.GetUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(expected); + + var manager = new AuthorizationManager(repository.Object); + + var result = await manager.GetUserAsync(new User { UserName = "jdoe" }); + + Assert.Same(expected, result); + repository.Verify(r => r.GetUserAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetProfilesForUserAsync_DelegatesToRepository() + { + var expected = new[] { new Profile { Id = 1, Name = "admin" } }; + var repository = new Mock(); + repository + .Setup(r => r.GetProfilesForUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(expected); + + var manager = new AuthorizationManager(repository.Object); + + var result = await manager.GetProfilesForUserAsync(new User { UserName = "jdoe" }); + + Assert.Same(expected, result); + } + + [Fact] + public void Constructor_NullRepository_Throws() + { + Assert.Throws(() => new AuthorizationManager(null!)); + } +} diff --git a/tests/Authorization.UnitTests/ClaimsEnrichmentMiddlewareTests.cs b/tests/Authorization.UnitTests/ClaimsEnrichmentMiddlewareTests.cs new file mode 100644 index 0000000..61e1afc --- /dev/null +++ b/tests/Authorization.UnitTests/ClaimsEnrichmentMiddlewareTests.cs @@ -0,0 +1,122 @@ +using System.Security.Claims; +using Authorization.Abstractions.Business; +using Authorization.Abstractions.Models; +using Authorization.Abstractions.Options; +using Authorization.Middleware; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; + +namespace Authorization.UnitTests; + +public class ClaimsEnrichmentMiddlewareTests +{ + private static readonly IOptions Options = + Microsoft.Extensions.Options.Options.Create(new ClaimsEnrichmentOptions()); + + [Fact] + public async Task Invoke_AnonymousUser_CallsNextWithoutEnrichment() + { + var manager = new Mock(MockBehavior.Strict); + var context = new DefaultHttpContext(); + var nextCalled = false; + + var middleware = new ClaimsEnrichmentMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, NullLogger.Instance, Options); + + await middleware.InvokeAsync(context, manager.Object); + + Assert.True(nextCalled); + Assert.DoesNotContain(context.User.Identities, i => i.HasClaim(c => c.Type == ClaimTypes.Name)); + manager.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Invoke_AuthenticatedUser_AddsUserAndRoleClaims() + { + var userId = Guid.NewGuid(); + var manager = new Mock(); + manager + .Setup(m => m.GetUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new User { Id = userId, UserName = "jdoe", Email = "jdoe@example.com" }); + manager + .Setup(m => m.GetProfilesForUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { new Profile { Id = 10, Name = "admin" }, new Profile { Id = 20, Name = "user" } }); + + var context = BuildAuthenticatedContext("jdoe"); + var nextCalled = false; + var middleware = new ClaimsEnrichmentMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, NullLogger.Instance, Options); + + await middleware.InvokeAsync(context, manager.Object); + + Assert.True(nextCalled); + Assert.Equal("jdoe@example.com", context.User.FindFirst(ClaimTypes.Email)?.Value); + Assert.Equal("jdoe", context.User.FindFirst(ClaimTypes.Name)?.Value); + Assert.Equal(userId.ToString(), context.User.FindFirst("IdUsuario")?.Value); + var roles = context.User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(); + Assert.Equal(new[] { "10", "20" }, roles); + } + + [Fact] + public async Task Invoke_MissingUserNameClaim_DoesNotEnrich() + { + var manager = new Mock(MockBehavior.Strict); + var context = BuildAuthenticatedContext(userName: null); + var middleware = new ClaimsEnrichmentMiddleware(_ => Task.CompletedTask, NullLogger.Instance, Options); + + await middleware.InvokeAsync(context, manager.Object); + + manager.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Invoke_UserNotFound_DoesNotAddClaims() + { + var manager = new Mock(); + manager + .Setup(m => m.GetUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((User?)null); + + var context = BuildAuthenticatedContext("ghost"); + var middleware = new ClaimsEnrichmentMiddleware(_ => Task.CompletedTask, NullLogger.Instance, Options); + + await middleware.InvokeAsync(context, manager.Object); + + Assert.Null(context.User.FindFirst("IdUsuario")); + manager.Verify(m => m.GetProfilesForUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Invoke_StoreThrows_DegradesGracefullyAndCallsNext() + { + // The core resilience guarantee: an identity-store failure must never + // crash the pipeline. + var manager = new Mock(); + manager + .Setup(m => m.GetUserAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("database is down")); + + var context = BuildAuthenticatedContext("jdoe"); + var nextCalled = false; + var middleware = new ClaimsEnrichmentMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }, NullLogger.Instance, Options); + + var exception = await Record.ExceptionAsync(() => middleware.InvokeAsync(context, manager.Object)); + + Assert.Null(exception); + Assert.True(nextCalled); + Assert.Null(context.User.FindFirst("IdUsuario")); + } + + private static DefaultHttpContext BuildAuthenticatedContext(string? userName) + { + var claims = new List(); + if (userName is not null) + { + claims.Add(new Claim("usuario", userName)); + } + + // A non-null authentication type makes the identity report IsAuthenticated == true. + var identity = new ClaimsIdentity(claims, authenticationType: "TestAuth"); + return new DefaultHttpContext { User = new ClaimsPrincipal(identity) }; + } +} diff --git a/tests/Authorization.UnitTests/ConverterTests.cs b/tests/Authorization.UnitTests/ConverterTests.cs new file mode 100644 index 0000000..f3554e6 --- /dev/null +++ b/tests/Authorization.UnitTests/ConverterTests.cs @@ -0,0 +1,50 @@ +using Authorization.Common; +using Entities = Authorization.Abstractions.Entities; +using Models = Authorization.Abstractions.Models; + +namespace Authorization.UnitTests; + +public class ConverterTests +{ + [Fact] + public void ConvertList_MapsEveryElement() + { + var source = new[] + { + new Entities.Profile { Id = 1, Name = "admin" }, + new Entities.Profile { Id = 2, Name = "user" }, + }; + + var result = Converter.ConvertList(source); + + Assert.Equal(2, result.Count); + Assert.Equal("admin", result[0].Name); + Assert.Equal("user", result[1].Name); + } + + [Fact] + public void ConvertList_EmptySource_ReturnsEmpty() + { + var result = Converter.ConvertList(Array.Empty()); + Assert.Empty(result); + } + + [Fact] + public void ConvertList_NullSource_Throws() + { + Assert.Throws( + () => Converter.ConvertList(null!)); + } + + [Fact] + public void Clone_ProducesIndependentCopy() + { + var original = new Models.User { Id = Guid.NewGuid(), UserName = "jdoe" }; + + var clone = Converter.Clone(original); + + Assert.NotNull(clone); + Assert.NotSame(original, clone); + Assert.Equal(original.UserName, clone!.UserName); + } +} diff --git a/tests/Authorization.UnitTests/GlobalUsings.cs b/tests/Authorization.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/Authorization.UnitTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/Authorization.UnitTests/MapperTests.cs b/tests/Authorization.UnitTests/MapperTests.cs new file mode 100644 index 0000000..2736756 --- /dev/null +++ b/tests/Authorization.UnitTests/MapperTests.cs @@ -0,0 +1,64 @@ +using Authorization.Common; +using Entities = Authorization.Abstractions.Entities; +using Models = Authorization.Abstractions.Models; + +namespace Authorization.UnitTests; + +public class MapperTests +{ + [Fact] + public void Map_CopiesMatchingProperties() + { + var id = Guid.NewGuid(); + var source = new Entities.User + { + Id = id, + UserName = "jdoe", + Email = "jdoe@example.com", + PasswordHash = "hash", + }; + + var result = Mapper.Map(source); + + Assert.NotNull(result); + Assert.Equal(id, result!.Id); + Assert.Equal("jdoe", result.UserName); + Assert.Equal("jdoe@example.com", result.Email); + Assert.Equal("hash", result.PasswordHash); + } + + [Fact] + public void Map_NullSource_ReturnsDefault() + { + var result = Mapper.Map(null); + Assert.Null(result); + } + + [Fact] + public void Map_AppliesTransformAfterCopy() + { + var source = new Entities.Profile { Id = 7, Name = "admin" }; + + var result = Mapper.Map( + source, + (src, dest) => dest.Name = src.Name!.ToUpperInvariant()); + + Assert.NotNull(result); + Assert.Equal(7, result!.Id); + Assert.Equal("ADMIN", result.Name); + } + + [Fact] + public void Map_IsConsistentAcrossCachedCalls() + { + // Exercises the per-type-pair property-map cache: a second call must + // produce the same result as the first. + var first = Mapper.Map(new Entities.Profile { Id = 1, Name = "a" }); + var second = Mapper.Map(new Entities.Profile { Id = 2, Name = "b" }); + + Assert.Equal(1, first!.Id); + Assert.Equal("a", first.Name); + Assert.Equal(2, second!.Id); + Assert.Equal("b", second.Name); + } +}