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)
Tenant.cs(modificado)User.cs(modificado)ASPBaseOIDCDbContext.cs(modificado)OpenIddictRegistrar.csCustomOpenIddictClaimsPrincipalHandler.csAuthConfigurer.cs(modificado)Startup.cs(modificado)AuthorizeController.csTokenController.csUserInfoController.csOpenIddictDataSeedContributor.csInitialHostDbBuilder.cs(modificado)KeycloakAuthenticationExtensions.cs(futuro)KeycloakSyncService.cs(futuro)- 3 módulos actualizados
- 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
- Certificados: Reemplazar development certs en producción
- Client Secrets: Mover a Azure Key Vault o User Secrets
- CORS: Configurar origins específicos por tenant
- Rate Limiting: Implementar en /connect/token
- 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