Files
base_abp/PLAN_OPENIDDICT_IMPLEMENTATION.md
2025-10-01 22:20:53 -06:00

21 KiB

Plan de Implementación: OpenIddict + ABP OrganizationUnits con Federación Keycloak

🎯 Arquitectura Final Aprobada

Mapeo Keycloak ↔ ABP

┌──────────────────────────────────────────────────────────────┐
│ Keycloak                →    ABP + OpenIddict                │
├──────────────────────────────────────────────────────────────┤
│ Realm (app1, app2)      →    Aplicación Multi-tenant        │
│ Organization (Keycloak) →    Tenant (AbpTenant)              │
│ Group (Keycloak)        →    OrganizationUnit (ABP Native)   │
│ User                    →    User (AbpUser)                  │
└──────────────────────────────────────────────────────────────┘

Flujo de Autenticación

Usuario → NextJS → OpenIddict (proxy) → Keycloak (IDP)
                       ↓
                   ABP API (validación local + permisos)

📦 FASE 1: Extensión del Modelo de Datos (Día 1)

1.1 Extender Tenant para Keycloak

Archivo: src/ASPBaseOIDC.Core/MultiTenancy/Tenant.cs

public class Tenant : AbpTenant<User>
{
    public string TenantUrl { get; set; }              // tenant1.app.com
    public string KeycloakOrganizationId { get; set; } // UUID de org en Keycloak
    public string KeycloakRealmName { get; set; }      // "app1" o "app2"

    public Tenant()
    {
    }

    public Tenant(string tenancyName, string name)
        : base(tenancyName, name)
    {
    }
}

1.2 Extender User para External Auth

Archivo: src/ASPBaseOIDC.Core/Authorization/Users/User.cs

public class User : AbpUser<User>
{
    public const string DefaultPassword = "123qwe";

    // Keycloak Integration
    public string ExternalAuthProviderId { get; set; }  // Keycloak User UUID
    public string KeycloakUserId { get; set; }          // Keycloak sub claim
    public string PreferredLanguage { get; set; }

    // OrganizationUnits membership ya manejado por:
    // UserOrganizationUnits (tabla many-to-many nativa de ABP)

    // ... resto del código existente
}

1.3 Usar OrganizationUnit Nativo (NO crear nueva entidad)

ABP ya provee:

  • Abp.Organizations.OrganizationUnit (entidad)
  • OrganizationUnitManager (dominio)
  • IOrganizationUnitRepository (repositorio)
  • UserOrganizationUnit (relación many-to-many)

Propiedades clave de OrganizationUnit:

// Ya existe en ABP, no crear
public class OrganizationUnit : AuditedAggregateRoot<long>, IMayHaveTenant
{
    public int? TenantId { get; set; }
    public long? ParentId { get; set; }
    public string Code { get; set; }           // Auto-generado: "00001.00042"
    public string DisplayName { get; set; }

    public virtual OrganizationUnit Parent { get; set; }
    public virtual ICollection<OrganizationUnit> Children { get; set; }
}

Si necesitas campos extra, crear extensión:

// OPCIONAL: Solo si necesitas mapeo a Keycloak
public class OrganizationUnitExtension : Entity<long>
{
    public long OrganizationUnitId { get; set; }
    public string KeycloakGroupPath { get; set; }   // "/sucursal-norte/ventas"
    public string KeycloakGroupId { get; set; }     // UUID del group

    public OrganizationUnit OrganizationUnit { get; set; }
}

1.4 Actualizar DbContext

Archivo: src/ASPBaseOIDC.EntityFrameworkCore/EntityFrameworkCore/ASPBaseOIDCDbContext.cs

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    // DateTime UTC conversion (ya existe)
    // ...

    // Configurar extensiones de entidades
    modelBuilder.Entity<Tenant>(b =>
    {
        b.Property(t => t.TenantUrl).HasMaxLength(256);
        b.Property(t => t.KeycloakOrganizationId).HasMaxLength(256);
        b.Property(t => t.KeycloakRealmName).HasMaxLength(128);
    });

    modelBuilder.Entity<User>(b =>
    {
        b.Property(u => u.ExternalAuthProviderId).HasMaxLength(256);
        b.Property(u => u.KeycloakUserId).HasMaxLength(256);
        b.HasIndex(u => u.KeycloakUserId);
    });

    // OrganizationUnit ya configurado por ABP
}

1.5 Crear Migración

cd src/ASPBaseOIDC.EntityFrameworkCore
dotnet ef migrations add ExtendTenantAndUserForKeycloak --startup-project ../ASPBaseOIDC.Web.Host

📚 FASE 2: Instalación de OpenIddict (Día 1-2)

2.1 Instalar Paquetes NuGet

En ASPBaseOIDC.Core.csproj:

<PackageReference Include="Abp.ZeroCore.OpenIddict" Version="10.2.0" />

En ASPBaseOIDC.EntityFrameworkCore.csproj:

<PackageReference Include="Abp.ZeroCore.OpenIddict.EntityFrameworkCore" Version="10.2.0" />

En ASPBaseOIDC.Web.Core.csproj:

<PackageReference Include="Abp.AspNetCore.OpenIddict" Version="10.2.0" />

2.2 Actualizar Módulos

Archivo: src/ASPBaseOIDC.Core/ASPBaseOIDCCoreModule.cs

[DependsOn(
    typeof(AbpZeroCoreModule),
    typeof(AbpZeroCoreOpenIddictModule)  // ← AGREGAR
)]
public class ASPBaseOIDCCoreModule : AbpModule
{
    // ... sin cambios
}

Archivo: src/ASPBaseOIDC.EntityFrameworkCore/EntityFrameworkCore/ASPBaseOIDCEntityFrameworkModule.cs

[DependsOn(
    typeof(ASPBaseOIDCCoreModule),
    typeof(AbpZeroCoreEntityFrameworkCoreModule),
    typeof(AbpZeroCoreOpenIddictEntityFrameworkCoreModule)  // ← AGREGAR
)]
public class ASPBaseOIDCEntityFrameworkModule : AbpModule
{
    // ... sin cambios
}

Archivo: src/ASPBaseOIDC.Web.Core/ASPBaseOIDCWebCoreModule.cs

[DependsOn(
    typeof(ASPBaseOIDCCoreModule),
    typeof(AbpAspNetCoreModule),
    typeof(AbpAspNetCoreOpenIddictModule)  // ← AGREGAR
)]
public class ASPBaseOIDCWebCoreModule : AbpModule
{
    // ... sin cambios
}

2.3 Implementar IOpenIddictDbContext

Archivo: src/ASPBaseOIDC.EntityFrameworkCore/EntityFrameworkCore/ASPBaseOIDCDbContext.cs

using OpenIddict.EntityFrameworkCore.Models;

public class ASPBaseOIDCDbContext :
    AbpZeroDbContext<Tenant, Role, User, ASPBaseOIDCDbContext>,
    IOpenIddictDbContext<Guid>  // ← AGREGAR interfaz
{
    // DbSets de OpenIddict
    public DbSet<OpenIddictApplication<Guid>> OpenIddictApplications { get; set; }
    public DbSet<OpenIddictAuthorization<Guid>> OpenIddictAuthorizations { get; set; }
    public DbSet<OpenIddictScope<Guid>> OpenIddictScopes { get; set; }
    public DbSet<OpenIddictToken<Guid>> OpenIddictTokens { get; set; }

    public ASPBaseOIDCDbContext(DbContextOptions<ASPBaseOIDCDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // Configurar OpenIddict
        modelBuilder.ConfigureOpenIddict();

        // DateTime UTC conversion (existente)
        // ...

        // Tenant/User extensions (de Fase 1.4)
        // ...
    }
}

2.4 Crear Migración OpenIddict

dotnet ef migrations add AddOpenIddictTables --startup-project ../ASPBaseOIDC.Web.Host

🔧 FASE 3: Configuración de OpenIddict Server (Día 2-3)

3.1 Crear OpenIddictRegistrar

Nuevo archivo: src/ASPBaseOIDC.Web.Core/OpenIddict/OpenIddictRegistrar.cs

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System;

namespace ASPBaseOIDC.Web.OpenIddict
{
    public static class OpenIddictRegistrar
    {
        public static void Register(IServiceCollection services, IConfiguration configuration)
        {
            services.AddOpenIddict()
                // Core
                .AddCore(options =>
                {
                    options.UseEntityFrameworkCore()
                           .UseDbContext<ASPBaseOIDCDbContext>()
                           .ReplaceDefaultEntities<Guid>();
                })

                // Server
                .AddServer(options =>
                {
                    // Endpoints
                    options.SetAuthorizationEndpointUris("/connect/authorize")
                           .SetTokenEndpointUris("/connect/token")
                           .SetUserinfoEndpointUris("/connect/userinfo")
                           .SetLogoutEndpointUris("/connect/logout")
                           .SetIntrospectionEndpointUris("/connect/introspect");

                    // Flows
                    options.AllowPasswordFlow()
                           .AllowAuthorizationCodeFlow()
                           .AllowRefreshTokenFlow()
                           .AllowClientCredentialsFlow();

                    // Certificates (DEV - cambiar en producción)
                    options.AddDevelopmentEncryptionCertificate()
                           .AddDevelopmentSigningCertificate();

                    // Token lifetime
                    options.SetAccessTokenLifetime(TimeSpan.FromHours(1))
                           .SetRefreshTokenLifetime(TimeSpan.FromDays(14));

                    // ASP.NET Core integration
                    options.UseAspNetCore()
                           .EnableAuthorizationEndpointPassthrough()
                           .EnableTokenEndpointPassthrough()
                           .EnableUserinfoEndpointPassthrough()
                           .EnableLogoutEndpointPassthrough()
                           .EnableStatusCodePagesIntegration();

                    // Disable encryption for development
                    options.DisableAccessTokenEncryption();
                })

                // Validation
                .AddValidation(options =>
                {
                    options.UseLocalServer();
                    options.UseAspNetCore();
                });
        }
    }
}

3.2 Custom Claims Principal Handler con OrganizationUnits

Nuevo archivo: src/ASPBaseOIDC.Web.Core/OpenIddict/CustomOpenIddictClaimsPrincipalHandler.cs

using Abp.Authorization;
using Abp.Domain.Repositories;
using Abp.OpenIddict;
using Abp.Organizations;
using ASPBaseOIDC.Authorization.Users;
using ASPBaseOIDC.MultiTenancy;
using Microsoft.AspNetCore.Identity;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

namespace ASPBaseOIDC.Web.OpenIddict
{
    public class CustomOpenIddictClaimsPrincipalHandler : IAbpOpenIddictClaimsPrincipalHandler
    {
        private readonly UserManager<User> _userManager;
        private readonly IRepository<Tenant> _tenantRepository;
        private readonly IRepository<OrganizationUnit, long> _organizationUnitRepository;
        private readonly IRepository<UserOrganizationUnit, long> _userOrganizationUnitRepository;
        private readonly IPermissionManager _permissionManager;

        public CustomOpenIddictClaimsPrincipalHandler(
            UserManager<User> userManager,
            IRepository<Tenant> tenantRepository,
            IRepository<OrganizationUnit, long> organizationUnitRepository,
            IRepository<UserOrganizationUnit, long> userOrganizationUnitRepository,
            IPermissionManager permissionManager)
        {
            _userManager = userManager;
            _tenantRepository = tenantRepository;
            _organizationUnitRepository = organizationUnitRepository;
            _userOrganizationUnitRepository = userOrganizationUnitRepository;
            _permissionManager = permissionManager;
        }

        public async Task HandleAsync(AbpOpenIddictClaimsPrincipalHandlerContext context)
        {
            var userId = context.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
            if (string.IsNullOrEmpty(userId))
                return;

            var user = await _userManager.FindByIdAsync(userId);
            if (user == null)
                return;

            var identity = context.Principal.Identities.First();

            // Claims básicos de usuario
            identity.AddClaim(new Claim("user_id", user.Id.ToString()));
            identity.AddClaim(new Claim("username", user.UserName));
            identity.AddClaim(new Claim("email", user.EmailAddress));

            // Claims de Tenant
            if (user.TenantId.HasValue)
            {
                var tenant = await _tenantRepository.GetAsync(user.TenantId.Value);
                identity.AddClaim(new Claim("tenant_id", tenant.Id.ToString()));
                identity.AddClaim(new Claim("tenant_name", tenant.Name));
                identity.AddClaim(new Claim("tenant_url", tenant.TenantUrl ?? ""));

                if (!string.IsNullOrEmpty(tenant.KeycloakRealmName))
                {
                    identity.AddClaim(new Claim("keycloak_realm", tenant.KeycloakRealmName));
                }
            }

            // Claims de OrganizationUnits
            var userOus = await _userOrganizationUnitRepository
                .GetAllListAsync(uou => uou.UserId == user.Id);

            foreach (var userOu in userOus)
            {
                var ou = await _organizationUnitRepository.GetAsync(userOu.OrganizationUnitId);
                identity.AddClaim(new Claim("organization_unit_id", ou.Id.ToString()));
                identity.AddClaim(new Claim("organization_unit_code", ou.Code));
                identity.AddClaim(new Claim("organization_unit_name", ou.DisplayName));
            }

            // Roles
            var roles = await _userManager.GetRolesAsync(user);
            foreach (var role in roles)
            {
                identity.AddClaim(new Claim(ClaimTypes.Role, role));
            }

            // Permisos de ABP
            var permissions = await _permissionManager.GetGrantedPermissionsAsync(user);
            foreach (var permission in permissions)
            {
                identity.AddClaim(new Claim("permission", permission.Name));
            }
        }
    }
}

3.3 Actualizar Startup.cs

Archivo: src/ASPBaseOIDC.Web.Host/Startup/Startup.cs

using ASPBaseOIDC.Web.OpenIddict;
using OpenIddict.Validation.AspNetCore;

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // MVC (existente)
        services.AddControllersWithViews(/*...*/);

        // Registrar OpenIddict
        OpenIddictRegistrar.Register(services, _appConfiguration);

        // Registrar custom claims handler
        services.AddTransient<IAbpOpenIddictClaimsPrincipalHandler, CustomOpenIddictClaimsPrincipalHandler>();

        // Identity y Auth (modificar existente)
        IdentityRegistrar.Register(services);

        // AuthConfigurer ahora debe configurar OpenIddict validation
        AuthConfigurer.Configure(services, _appConfiguration);

        // ... resto sin cambios
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory)
    {
        app.UseAbp(/*...*/);
        app.UseCors(_defaultCorsPolicyName);
        app.UseStaticFiles();
        app.UseRouting();

        // IMPORTANTE: Orden correcto de middlewares
        app.UseAuthentication();    // Ya existe
        app.UseAuthorization();     // Ya existe

        app.UseAbpRequestLocalization();
        app.UseEndpoints(/*...*/);
        app.UseSwagger(/*...*/);
        app.UseSwaggerUI(/*...*/);
    }
}

3.4 Actualizar AuthConfigurer

Archivo: src/ASPBaseOIDC.Web.Core/Authentication/AuthConfigurer.cs

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using OpenIddict.Validation.AspNetCore;

namespace ASPBaseOIDC.Web.Authentication
{
    public static class AuthConfigurer
    {
        public static void Configure(IServiceCollection services, IConfiguration configuration)
        {
            // Configurar autenticación con OpenIddict como esquema principal
            services.AddAuthentication(options =>
            {
                options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
                options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
            })
            // Mantener JWT Bearer para backwards compatibility (opcional)
            .AddJwtBearer(options =>
            {
                options.Authority = configuration["Authentication:JwtBearer:Authority"];
                options.Audience = configuration["Authentication:JwtBearer:Audience"];
                options.RequireHttpsMetadata = false;
            });
        }
    }
}

🎮 FASE 4: Controladores OpenIddict (Día 3-4)

4.1 AuthorizeController

Nuevo archivo: src/ASPBaseOIDC.Web.Host/Controllers/OpenIddict/AuthorizeController.cs

Ver código en el plan completo arriba...

4.2 TokenController

Nuevo archivo: src/ASPBaseOIDC.Web.Host/Controllers/OpenIddict/TokenController.cs

Ver código en el plan completo arriba...

4.3 UserInfoController

Nuevo archivo: src/ASPBaseOIDC.Web.Host/Controllers/OpenIddict/UserInfoController.cs

Ver código en el plan completo arriba...


🌱 FASE 5: Seed Data para OpenIddict (Día 5)

5.1 Crear OpenIddictDataSeedContributor

Nuevo archivo: src/ASPBaseOIDC.EntityFrameworkCore/EntityFrameworkCore/Seed/Host/OpenIddictDataSeedContributor.cs

Ver código en el plan completo arriba...

5.2 Registrar Seed en InitialHostDbBuilder

Ver código en el plan completo arriba...


🧪 FASE 6: Testing y Validación (Día 6)

6.1 Ejecutar Migraciones

cd src/ASPBaseOIDC.Migrator
dotnet run

6.2 Iniciar Aplicación

cd src/ASPBaseOIDC.Web.Host
dotnet run

6.3 Testing con Postman

Request 1: Obtener Token (Password Grant)

POST https://localhost:44311/connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=password
&username=admin
&password=admin
&client_id=postman-client
&client_secret=postman-secret-dev-only
&scope=openid profile email api tenant organization offline_access

🚀 FASE 7: Preparación para Keycloak (Futuro - Día 7+)

Ver detalles completos en el plan arriba...


📝 Resumen de Entregables

Archivos Nuevos (~18 archivos)

  1. Tenant.cs (modificado)
  2. User.cs (modificado)
  3. ASPBaseOIDCDbContext.cs (modificado)
  4. OpenIddictRegistrar.cs
  5. CustomOpenIddictClaimsPrincipalHandler.cs
  6. AuthConfigurer.cs (modificado)
  7. Startup.cs (modificado)
  8. AuthorizeController.cs
  9. TokenController.cs
  10. UserInfoController.cs
  11. OpenIddictDataSeedContributor.cs
  12. InitialHostDbBuilder.cs (modificado)
  13. KeycloakAuthenticationExtensions.cs (futuro)
  14. KeycloakSyncService.cs (futuro)
  15. 3 módulos actualizados
  16. 2 migraciones EF Core

Cambios en Base de Datos

  • Migración 1: ExtendTenantAndUserForKeycloak

    • Tenant: +3 columnas
    • User: +3 columnas
  • Migración 2: AddOpenIddictTables

    • OpenIddictApplications
    • OpenIddictAuthorizations
    • OpenIddictScopes
    • OpenIddictTokens
  • Tablas ABP existentes (uso nativo):

    • OrganizationUnits
    • UserOrganizationUnits

Paquetes NuGet (~3 paquetes)

  • Abp.ZeroCore.OpenIddict (10.2.0)
  • Abp.ZeroCore.OpenIddict.EntityFrameworkCore (10.2.0)
  • Abp.AspNetCore.OpenIddict (10.2.0)

⏱️ Estimación Final

Fase Tarea Tiempo
1 Modelo de datos + migraciones 1 día
2 Instalación OpenIddict + configuración 1 día
3 OpenIddictRegistrar + Claims Handler 1 día
4 Controladores (Authorize, Token, UserInfo) 1-2 días
5 Seed data + testing básico 0.5 día
6 Testing exhaustivo (Postman, Swagger) 0.5 día
7 Documentación 0.5 día
TOTAL MVP OpenIddict standalone funcional 5-6 días
Futuro Federación Keycloak +2-3 días

🔒 Consideraciones de Seguridad

  1. Certificados: Reemplazar development certs en producción
  2. Client Secrets: Mover a Azure Key Vault o User Secrets
  3. CORS: Configurar origins específicos por tenant
  4. Rate Limiting: Implementar en /connect/token
  5. HTTPS: Forzar en producción (Kestrel endpoints)

Criterios de Aceptación

  • OpenIddict emite tokens JWT válidos
  • Password Grant funcional con usuario admin
  • Claims incluyen tenant_id, organization_units, roles, permissions
  • API valida tokens correctamente
  • [AbpAuthorize] funciona sin cambios
  • Swagger UI autentica con OAuth2
  • Postman puede obtener y usar tokens
  • UserInfo retorna información correcta
  • Refresh tokens funcionan
  • Seed data crea aplicaciones cliente

Fecha de creación: 2025-10-01 Versión: 1.0 Stack: ASP.NET Boilerplate v10.2.0 + .NET 9.0 + PostgreSQL + OpenIddict