Introducción
En esta parte 1 realizaremos algo muy interesante y que solía ser muy complicado antes. Realizaremos un servidor de autenticación utilizando OpenID Connect, y, además, será multi-tenant.
Si no queremos pagar por servicios como OAuth0 o Azure AD B2C, podemos crear nuestro propio servidor de autenticación. Si trabajas en una empresa que cuenta con muchas soluciones de software y quieres centralizar tu proceso de autenticación en un solo lugar, crear un servidor de autenticación es buena opción.
Si estas creando un servidor de autenticación, a veces no es necesario que sea multi-tenant, pero en caso de que tengas varios productos de software o varias instalaciones, y es necesario que los usuarios estén segmentados según el cliente que lo usará, esta puede ser una buena solución.
Recomendación 💡: A veces es mejor delegar esta responsabilidad de seguridad y autenticación a servicios como Azure AD, estos invierten demasiado tiempo y dinero en mejorar la seguridad de sus servicios. En caso de que necesites crear tu propia solución, que igual puede ser segura, sigue leyendo este post 🤭.
¿Qué realizaremos?
Ya hemos visto en que consiste el crear aplicaciones multi-tenant con ASP.NET Core y también ya hemos visto como realizar autenticación con OpenID Connect.
Algo que me han pedido mucho, es mezclar estos requerimientos en uno solo.
Por lo que crearemos, de la manera más simple que podamos, dos aplicaciones web multi-tenant. Estas aplicaciones serán totalmente independientes, es como si crearemos una aplicación web multi-tenant y también crearemos un servicio como Auth0 (este servicio, por ser SaaS, es multi-tenant).
Por lo que podremos tener un servidor de autenticación con bases de datos de usuarios independientes según el tenant. Aunque ya vimos en la serie de artículos multi-tenant, podemos también utilizar una sola base de datos en caso de ser necesario y válido.
También tendremos una aplicación web cliente multi-tenant configurada para que funcione con OpenID Connect, todo esto aislado en cada tenant. Cada tenant tendrá su configuración OpenID y no se compartirá nada de eso.
Por lo poco que has leído, te darás cuenta de que estaremos creando dos soluciones que trabajarán de forma independiente.
Creando la Solución
Como siempre, te animo que leas estos artículos siguiendo el código fuente, que puedes encontrar aquí en mi GitHub.
Para comenzar con esta solución, comenzaremos creando un proyecto Web con dotnet new web
, o sea, proyectos web vacíos:
- MultiTenants.IdentityServer (este post): Servidor de autenticación con OpenID Connect utilizando OpenIddict para el manejo del protocolo OIDC y para manejar el multitenancy de una forma simplificada utilizaremos la librería Finbuckle.MultiTenant (muy buena, por cierto, la serie multitenant está basada en esta librería).
- MultiTenants.Web (parte 2): Aplicación Web cliente que tendrá también una configuración multi-tenant y OpenID como método de autenticación. Aquí cada tenant tendrá su propia configuración de OpenID (su propio ClientId, Secret, incluso authority).
Simplemente tendremos un proyecto web vacío, con la finalidad de crear todo desde cero.
Nota 👀: En lugar de crear el IdentityServer como un proyecto web vacío, puedes usar la plantilla que contiene autenticación individual, básicamente estaremos replicándolo.
MultiTenants.IdentityServer
Para comenzar a crear el servidor de autenticación, necesitaremos las siguientes librerías de apoyo:
<PackageReference Include="Finbuckle.MultiTenant.AspNetCore" Version="6.7.3" />
<PackageReference Include="Finbuckle.MultiTenant.EntityFrameworkCore" Version="6.7.3" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="6.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="6.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="OpenIddict" Version="3.1.1" />
<PackageReference Include="OpenIddict.AspNetCore" Version="3.1.1" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="3.1.1" />
Te resumo su uso:
- Finbuckle.MultiTenant.*: Nos permite crear aplicaciones multi-tenant de una forma muy simplificada. Si has visto la serie que escribí sobre multi-tenancy, es un approach muy similar (idéntico) solo que nos ahorramos la implementación.
-
Microsoft.AspNetCore.Identity.*: El sistema ya por default de ASP.NET Core para manejo de usuarios, 100% recomendado que siempre utilices Identity si comienzas un proyecto y este necesita manejo de usuarios.
- También esta librería cuenta con UI por default, así no perdemos tiempo creando vistas de Registro o autenticación de usuarios.
- Microsoft.EntityFrameworkCore.: De ley usaremos EF Core, estoy seguro de que ya sabes para qué es.
- OpenIddict.: Librería que nos ayuda con el protocolo OIDC del lado del Servidor.
Agregando Soporte Multi-Tenant con Finbuckle.MultiTenant
El soporte multi-tenant es muy sencillo con esta librería, tiene una documentación muy extensa ya que las cosas se pueden poner muy messy.
Para comenzar, crearemos un Entity que representará cada Tenant registrado y cada tenant tendrá su propia base de datos.
Domain > Entities > TenantAdmin
Dentro de TenantAdmin
se podrán poner todas las tablas referentes al manejo de tenants (cosa que ya vimos en posts pasados). Pero por ahora solo usaremos un Entity.
using Finbuckle.MultiTenant;
namespace MultiTenants.IdentityServer.Domain.Entities.TenantAdmin;
public class MultiTenantInfo : ITenantInfo
{
public string Id { get; set; }
public string Identifier { get; set; }
public string Name { get; set; }
public string ConnectionString { get; set; }
}
Persistence > TenandAdminDbContext
Este contexto será manejado o heredado de una preimplementación incluida en Finbuckle:
using Finbuckle.MultiTenant.Stores;
using Microsoft.EntityFrameworkCore;
using MultiTenants.IdentityServer.Domain.Entities.TenantAdmin;
namespace MultiTenants.IdentityServer.Persistence;
public class TenantAdminDbContext : EFCoreStoreDbContext<MultiTenantInfo>
{
public TenantAdminDbContext(DbContextOptions<TenantAdminDbContext> options)
: base(options)
{
}
}
Este contexto base será usada por la librería Finbuckle (cosa que haremos más adelante) para encontrar los tenants registrados.
Persistence > IdentityServerDbContext
De una vez crearemos el contexto principal del Identity Server y lo dejaremos configurado para que tenga soporte multitenant y multi-database:
using Finbuckle.MultiTenant;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using MultiTenants.IdentityServer.Domain.Entities.TenantAdmin;
namespace MultiTenants.IdentityServer.Persistence;
public class IdentityServerDbContext : IdentityDbContext
{
private readonly MultiTenantInfo _tenant;
private readonly IWebHostEnvironment _env;
private readonly IConfiguration _config;
public IdentityServerDbContext(
DbContextOptions<IdentityServerDbContext> options,
IMultiTenantContextAccessor<MultiTenantInfo> accessor,
IWebHostEnvironment env,
IConfiguration config) : base(options)
{
_tenant = accessor.MultiTenantContext?.TenantInfo;
_env = env;
_config = config;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
string connectionString;
if (_tenant is null && _env.IsDevelopment())
{
// Si no hay tenant y estamos en desarrollo, es un comando <dotnet ef>
connectionString = _config.GetConnectionString("DefaultConnection");
}
else
{
// Tenant connection string
connectionString = _tenant.ConnectionString;
}
optionsBuilder.UseSqlServer(connectionString);
optionsBuilder.UseOpenIddict();
base.OnConfiguring(optionsBuilder);
}
}
Cosas relevantes en esta parte:
- IdentityDbContext: Implementación del DbContext de Identity Core, este incluye usuarios, roles, claims, etc.
-
OnConfiguring: En este punto hacemos que el DbContext utilice el proveedor de SQL Server pero con un
ConnectionString
dinámico. Este va a variar según el tenant.- De esta forma hacemos que exista una base de datos por cada tenant en el Identity Server.
- Esta base de datos guardará usuarios, contraseñas, roles, etc. Todo independiente a cada tenant.
- Esto hará que un usuario exista en un tenant (en su base de datos) y jamás se mezclen.
- UseOpenIddict: OpenIddict tiene que registrar sus propios Entities en este contexto, ya que aquí se llevará acabo la emisión de Tokens y otras cosas.
Hasta este punto ya tenemos dos DB Context, el que guarda la información de cada Tenant y la base de datos que crecerá indefinidamente por cada tenant.
Para finalizar con las partes de "configuración", ahora crearemos la configuración de dependencias y middlewares que se usarán.
DependencyConfig
Me gusta centralizar la configuración de dependencias con métodos de extensión, así que eso haremos con la clase estática DependencyConfig
:
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using MultiTenants.IdentityServer.Domain.Entities.TenantAdmin;
using MultiTenants.IdentityServer.Persistence;
namespace MultiTenants.IdentityServer;
public static class DependencyConfig
{
public static IServiceCollection AddAspNetServices(this IServiceCollection services)
{
services.AddRazorPages();
services.AddControllers();
services.AddDatabaseDeveloperPageExceptionFilter();
return services;
}
public static IServiceCollection AddMultiTenantSupport(this IServiceCollection services)
{
services.AddMultiTenant<MultiTenantInfo>()
.WithHostStrategy()
.WithEFCoreStore<TenantAdminDbContext, MultiTenantInfo>()
.WithPerTenantAuthentication()
.WithPerTenantOptions<CookieAuthenticationOptions>((o, tenant) =>
{
o.Cookie.Name = $".Identity_{tenant.Identifier}";
o.LoginPath = "/Identity/Account/Login";
});
return services;
}
public static IServiceCollection AddIdentity(this IServiceCollection services)
{
services.AddDefaultIdentity<IdentityUser>(options =>
{
// Password settings.
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequiredLength = 6;
options.Password.RequiredUniqueChars = 1;
// User settings.
options.User.RequireUniqueEmail = true;
options.SignIn.RequireConfirmedEmail = false;
}).AddEntityFrameworkStores<IdentityServerDbContext>();
return services;
}
public static IServiceCollection AddDbContexts(this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<TenantAdminDbContext>(options =>
options.UseSqlServer(config.GetConnectionString("TenantAdmin")));
services.AddDbContext<IdentityServerDbContext>();
return services;
}
public static IServiceCollection AddOpenIdConnect(this IServiceCollection services)
{
services.AddOpenIddict()
// Register the OpenIddict core components.
.AddCore(options =>
{
// Configure OpenIddict to use the EF Core stores/models.
options
.UseEntityFrameworkCore()
.UseDbContext<IdentityServerDbContext>();
})
// Register the OpenIddict server components.
.AddServer(options =>
{
options
.AllowAuthorizationCodeFlow()
.RequireProofKeyForCodeExchange()
.AllowRefreshTokenFlow();
options
.SetTokenEndpointUris("/connect/token")
.SetAuthorizationEndpointUris("/connect/authorize")
.SetUserinfoEndpointUris("/connect/userinfo")
.SetLogoutEndpointUris("/connect/logout");
// Encryption and signing of tokens
options
.AddEphemeralEncryptionKey()
.AddEphemeralSigningKey()
.DisableAccessTokenEncryption();
// Register scopes (permissions)
options.RegisterScopes("api");
options.RegisterScopes("profile");
// Register the ASP.NET Core host and configure the ASP.NET Core-specific options.
options
.UseAspNetCore()
.EnableTokenEndpointPassthrough()
.EnableAuthorizationEndpointPassthrough()
.EnableUserinfoEndpointPassthrough()
.EnableLogoutEndpointPassthrough();
});
return services;
}
}
Resumen:
- AddAspNetServices: Aquí simplemente estamos agregando los servicios para poder usar Razor Pages y MVC Controllers
-
AddMultiTenantSupport: Configuración y dependencias requeridas por la librería de Multi-tenants. Hay más detalle en la serie multi-tenant que mencioné anteriormente, de igual forma, el modo multi-tenant que se agrega es el siguiente:
- WithHostStrategy: Cada tenant se va a diferenciar según el primer segmento del Host. Es decir: para https://contoso.enterprise.com el identificador del tenant será contoso. Para modo desarrollo (utilizando https://localhost:xxxx pues el identificador del tenant será localhost)
- WithEFCoreStore: Aquí se especifica el DB Context que guardará los tenants, aquí mismo se define el acceso a datos y ya no tenemos que implementarlo
- WithPerTenantAuthentication: Marvilla de esta librería, aquí estamos diciendo que cualquier esquema de autenticación que tengamos (ejem. Cookies) tendrá una configuración dinámica según el tenant.
- Este tema es más extenso y complicado, involucra el uso de
IOptions<T>
pattern, Monitor y otras cosas de ASP.NET Core. El punto aquí es que la configuración se puede hacer dinámica según el tenant. -
WithPerTenantOptions: Aquí estamos viendo un ejemplo de cómo podemos configurar la aplicación según el tenant. En este caso, estamos configurando los settings
CookieAuthenticationOptions
, por lo que estamos haciendo que el nombre de la Cookie de autenticación sea diferente según el tenant.
- AddIdentity: Aquí simplemente agregamos Identity Core y su implementación Default (incluirá UI y configuración que yo pongo para no batallar con los passwords, no te recomiendo que lo dejes así 🤭).
- AddDbContexts: Aquí se registran los dos contextos que tenemos, uno especificando el uso de SQL Server y su connection string (El Tenant Admin) y el otro sin especificar su cadena de conexión, ya que eso se hará de forma dinámica según el Tenant que accede.
- AddOpenIdConnect: Configuramos OpenID Connect con OpenIddict, esta configuración es igual a la del post que ya había escrito antes, pero en esencia estamos habilitando el flujo Authorization code flow de OIDC.
Hasta aquí ya tendríamos listo la configuración de dependencias. Falta el Program.cs para usar todo esto.
Program.cs
using Finbuckle.MultiTenant;
using MultiTenants.IdentityServer;
using MultiTenants.IdentityServer.Domain.Entities.TenantAdmin;
using MultiTenants.IdentityServer.Persistence;
using OpenIddict.Abstractions;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddDbContexts(builder.Configuration)
.AddIdentity()
.AddOpenIdConnect()
.AddMultiTenantSupport()
.AddAspNetServices();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseMigrationsEndPoint();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseMultiTenant();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
app.MapControllers();
app.Run();
Aquí la configuración es muy normal, simplemente hacemos uso de los métodos de extensión previamente definidos y además configuramos los middlewares.
El middleware que sobre sale es el UseMultiTenant
. Este proviene de la librería Finbuckle y lo que hace es ejecutar lo configurado en AddMultiTenantSupport
.
Autenticación con ASP.NET Identity Core
Hasta este punto ya tenemos casi todo para poder ejecutar el servicio, pero para completar el uso de Identity UI necesitamos de Bootstrap y varias librerías de Javascript.
Para eso haremos uso de libman, para que este instale las librerías que necesitamos (que en resumen es bootstrap y jquery).
En raíz creamos el archivo libman.json:
{
"version": "1.0",
"defaultProvider": "cdnjs",
"libraries": [
{
"provider": "cdnjs",
"library": "bootstrap@5.2.0",
"destination": "wwwroot/lib/bootstrap/"
},
{
"provider": "cdnjs",
"library": "jquery@3.6.0",
"destination": "wwwroot/lib/jquery/"
},
{
"provider": "cdnjs",
"library": "jquery-validate@1.19.5",
"destination": "wwwroot/lib/jquery-validate/",
"files": [
"additional-methods.js",
"additional-methods.min.js",
"jquery-validation-sri.json",
"jquery.validate.js",
"jquery.validate.min.js"
]
},
{
"provider": "cdnjs",
"library": "jquery-validation-unobtrusive@4.0.0",
"destination": "wwwroot/lib/jquery-validation-unobtrusive/"
}
]
}
Restauramos las dependencias y tendremos todo el frontend que necesitamos. Si no sabes usar libman aquí puedes leer más al respecto (Visual Studio lo hace todo visual, pero con dotnet cli hay que hacer pasos adicionales).
ASP.NET Identity Core define varias vistas en Razor Pages por default, estas se encuentran en un Area de ASP.NET, por lo que registramos esa área para especificar nuestro Layout (porque tal vez, queremos cambiar estilos o el layout mismo).
Areas > Identity > Pages > ViewStart
Esto es muy sencillo, simplemente indicamos el Layout que queremos que Identity use (este aún no existe, lo crearemos enseguida).
@{
Layout = "/Pages/Shared/_Layout.cshtml";
}
Lo que resta, son las vistas de nuestra aplicación y los enlaces para mandar a la autenticación y otro tipo de cosas. Te recuerdo que si quieres evitarte todo esto, puedes usar la plantilla default que existe con esa implementación.
Nota 👀: No me gusta utilizar las plantillas porque a veces agrega cosas que no necesitas o no sabes para que son. Me gusta crear las plantillas desde cero por varias razones, una de las importantes es aprender.
Pages > Shared > Layout
@using Finbuckle.MultiTenant
@using MultiTenants.IdentityServer.Domain.Entities.TenantAdmin
@inject IMultiTenantContextAccessor<MultiTenantInfo> Tenant
@{
var tenantName = Tenant.MultiTenantContext?.TenantInfo?.Name ?? "no-tenant";
var identifier = Tenant.MultiTenantContext?.TenantInfo?.Identifier ?? "no-tenant";
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - @tenantName</title>
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/MultiTenants.IdentityServer.styles.css" asp-append-version="true" />
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container">
<a class="navbar-brand" asp-area="" asp-page="/Index">Identity - @tenantName</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
</li>
</ul>
<partial name="_LoginPartial" />
</div>
</div>
</nav>
</header>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
© 2022 - MultiTenants.IdentityServer - <a asp-area="" asp-page="/Privacy">Privacy</a>
</div>
</footer>
<script src="~/lib/jquery/jquery.min.js"></script>
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>
Pages > Index
@page
@model IndexModel
@{
ViewData["Title"] = "Home page";
}
<div class="text-center">
@if(User.Identity.IsAuthenticated)
{
<h1 class="display-4">Welcome, @User.Identity.Name</h1>
<ul>
@foreach(var claim in User.Claims)
{
<li>@claim.Type: @claim.Value</li>
}
</ul>
}
else
{
<p>Sign in please</p>
}
</div>
Pages > Shared > LoginPartial
@using Finbuckle.MultiTenant
@using Microsoft.AspNetCore.Identity
@using MultiTenants.IdentityServer.Domain.Entities.TenantAdmin
@inject SignInManager<IdentityUser> SignInManager
@inject UserManager<IdentityUser> UserManager
@inject IMultiTenantContextAccessor<MultiTenantInfo> Tenant
@{
var identifier = Tenant.MultiTenantContext?.TenantInfo?.Identifier ?? "no-tenant";
}
<ul class="navbar-nav">
@if (SignInManager.IsSignedIn(User))
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Manage/Index" title="Manage">Hello @User.Identity?.Name!</a>
</li>
<li class="nav-item">
<form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post">
<button type="submit" class="nav-link btn btn-link text-dark">Logout</button>
</form>
</li>
}
else
{
<li class="nav-item">
<a class="nav-link text-dark"
asp-area="Identity"
asp-page="/Account/Register">Register</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark"
asp-area="Identity"
asp-page="/Account/Login">Login</a>
</li>
}
</ul>
Migraciones de DB Context's
Ya tenemos todo listo para correr la aplicación, pero primero que nada hay que crear la base de datos (y sus migraciones iniciales):
dotnet ef migrations add FirstMigration --context IdentityServerDbContext -o Persistence/Migrations/IdentityServer
dotnet ef database update --context IdentityServerDbContext
dotnet ef migrations add TenantsFirstMigration --context TenantAdminDbContext -o Persistence/Migrations/TenantAdmin
dotnet ef database update --context TenantAdminDbContext
Aquí, ya debes de verificar que tengas tus bases de datos creadas y con las tablas configuradas:
Nota 👀: Si esto no te funcionó, probablemente no configuraste las cadenas de conexión en
appsettings.json
revisa el código fuente para cualquier duda.
Seed Data (información de pruebas)
Para por fin correr el servicio, hay que agregar información de prueba para poder probar, esto, dentro del Program.cs:
using Finbuckle.MultiTenant;
using MultiTenants.IdentityServer;
using MultiTenants.IdentityServer.Domain.Entities.TenantAdmin;
using MultiTenants.IdentityServer.Persistence;
using OpenIddict.Abstractions;
// ... código omitido
await SeedDefaultClients();
await SetupTenants();
app.Run();
// OpenIddict info
async Task SeedDefaultClients()
{
using var scope = app.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<IdentityServerDbContext>();
var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
await context.Database.EnsureCreatedAsync();
var client = await manager.FindByClientIdAsync("tenant01");
if (client is null)
{
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = "tenant01",
ClientSecret = "tenant01-web-app-secret",
DisplayName = "Multi-Tenant Web Application 01",
RedirectUris = { new Uri("https://localhost:xxxx/signin-oidc") },
PostLogoutRedirectUris = { new Uri("https://localhost:xxxx/signout-callback-oidc") },
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.ClientCredentials,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.Endpoints.Logout,
OpenIddictConstants.Permissions.Prefixes.Scope + "api",
OpenIddictConstants.Permissions.Prefixes.Scope + "profile",
OpenIddictConstants.Permissions.ResponseTypes.Code
}
});
await manager.CreateAsync(new OpenIddictApplicationDescriptor
{
ClientId = "tenant02",
ClientSecret = "tenant02-web-app-secret",
DisplayName = "Multi-Tenant Web Application 02",
RedirectUris = { new Uri("https://tenant2.localhost:xxxx/signin-oidc") },
PostLogoutRedirectUris = { new Uri("https://tenant2.localhost:xxxx/signout-callback-oidc") },
Permissions =
{
OpenIddictConstants.Permissions.Endpoints.Authorization,
OpenIddictConstants.Permissions.Endpoints.Token,
OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
OpenIddictConstants.Permissions.GrantTypes.ClientCredentials,
OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
OpenIddictConstants.Permissions.Endpoints.Logout,
OpenIddictConstants.Permissions.Prefixes.Scope + "api",
OpenIddictConstants.Permissions.Prefixes.Scope + "profile",
OpenIddictConstants.Permissions.ResponseTypes.Code
}
});
}
}
// Multitenant info
async Task SetupTenants()
{
using var scope = app.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IMultiTenantStore<MultiTenantInfo>>();
var tenants = await store.GetAllAsync();
if (tenants.Count() > 0)
{
return;
}
await store.TryAddAsync(new MultiTenantInfo
{
Id = Guid.NewGuid().ToString(),
Identifier = "localhost",
Name = "My Identity Dev Tenant",
ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=MultiTenants.IdentityServer01;Trusted_Connection=True;MultipleActiveResultSets=true"
});
await store.TryAddAsync(new MultiTenantInfo
{
Id = Guid.NewGuid().ToString(),
Identifier = "tenant2",
Name = "My Identity Dev Tenant 2",
ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=MultiTenants.IdentityServer02;Trusted_Connection=True;MultipleActiveResultSets=true"
});
}
-
SeedDefaultClients: Aquí estamos registrando dos aplicaciones clientes que podrán autenticarse, estas son de prueba y en producción no debería de estar así. Se crearán en la base de datos default creada para localhost (con las migraciones).
- De igual forma, se están registrando dos clientes, pero solo se usará el primero (la idea es que el 2do lo usemos en una 2da base de datos para probar)
- SetupTenants: Registramos los dos tenants que queremos probar, el primero se usará con https://localhost:xxx y el 2do con https://tenant2.localhost:xxxx
Probando la solución
Por fin podremos correr el Identity Server con Identity Core y multi-tenant.
Al entrar, lo primero que debemos de notar (si es que te cargó jaja) es el nombre del tenant:
Y si entramos al Login o Register, deberían de salir las UI Default de Identity:
Y si nos registramos o iniciamos sesión, deberíamos de poder ver los Claims emitidos (y guardados en la Cookie) de autenticación:
Y si verificamos, vemos que se ha guardado la cookie con el nombre del tenant:
Probando el segundo Tenant
Para poder probar el 2do tenant, debemos tener configurado https://tenant2.localhost para que apunte a 127.0.0.1 (o sea, localhost).
Al cambiar el URL al tenant correspondiente, se abrirá todo como nuevo, apuntando al 2do tenant:
Si intentas iniciar sesión con el usuario que acabas de crear, no podrás, por que pertenece a otra base de datos y de otro tenant:
Nota 👀: Por si no te diste cuenta, tienes que crear la base de datos manualmente de este tenant, como lo pusimos en el
connectionString
esta base de datos se debe de llamar MultiTenants.IdentityServer02.
Aun no terminamos...
Integración OpenID Connect
Faltan los Endpoints que atenderán las llamadas OpenID Connect de aplicaciones cliente. Estos endpoints ya los definimos en el DependencyConfig
y solo queda hacer lo siguiente.
Controllers > AuthorizationController
Crearemos el primer y único controlador de la aplicación, este funcionará para recibir las llamadas de OpenID Connect (justo como lo hicimos en el post de OpenID).
Quedará casi igual:
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Security.Claims;
namespace MultiTenants.IdentityServer.Controllers;
public class AuthorizationController : ControllerBase
{
[HttpGet("~/connect/authorize")]
[HttpPost("~/connect/authorize")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Authorize()
{
// Validamos que se trate de una solicitud válida de OIDC
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
// Verificamos si actualmente el usuario ya está autenticado
var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);
// Si no hay información de autenticación en la Cookie, lo redireccionamos al Path
// de inicio de sesión según el esquema configurado (/Identity/Account/Login)
if (!result.Succeeded)
{
return Challenge(
authenticationSchemes: IdentityConstants.ApplicationScheme,
properties: new AuthenticationProperties
{
RedirectUri = Request.PathBase + Request.Path + QueryString.Create(
Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList())
});
}
// Si llegamos hasta aquí, el usuario está autenticado
// creamos los claims que necesitemos
var claims = new List<Claim>
{
// 'subject' claim which is required
new Claim(OpenIddictConstants.Claims.Subject, result.Principal.Identity.Name),
new Claim(OpenIddictConstants.Claims.Username, result.Principal.Identity.Name),
new Claim(OpenIddictConstants.Claims.Audience, "IdentityServerWebClients"), // TODO: Hacer esto dinámico.
};
var email = result.Principal.Claims.FirstOrDefault(q => q.Type == ClaimTypes.Email);
if (email is not null)
{
claims.Add(new Claim(OpenIddictConstants.Claims.Email, email.Value));
}
var claimsIdentity = new ClaimsIdentity(claims, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
// Al agregar claims al User Principal, tenemos que especificar si se va al ID Token o Access Token
foreach (var claim in claimsPrincipal.Claims)
{
claim.SetDestinations(claim.Type switch
{
// Si se dió permiso del scope "profile" agregamos el claim "name" a los dos Json Web Tokens
OpenIddictConstants.Claims.Name when claimsPrincipal.HasScope(OpenIddictConstants.Scopes.Profile) => new[]
{
OpenIddictConstants.Destinations.AccessToken,
OpenIddictConstants.Destinations.IdentityToken
},
// Nunca agreguemos "secret_value" a ningún token, ya que estos no van encriptados
// En los autorization codes si van encriptados, pero en los JWTs no.
"secret_value" => Array.Empty<string>(),
// Cualquier otro claim, lo agregamos al Access Token
_ => new[]
{
OpenIddictConstants.Destinations.AccessToken
}
});
}
// Ejecutar el SignIn con el esquema de OpenIddict, disparará la generación del Autorization Code.
// Con este Autorization code podemos obtener más adelante los JSON Web Tokens (Identity y Access).
return SignIn(claimsPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
[HttpPost("~/connect/token")]
public async Task<IActionResult> Exchange()
{
var request = HttpContext.GetOpenIddictServerRequest() ??
throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
ClaimsPrincipal claimsPrincipal;
if (request.IsAuthorizationCodeGrantType())
{
// Retrieve the claims principal stored in the authorization code
claimsPrincipal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
}
else if (request.IsRefreshTokenGrantType())
{
// Retrieve the claims principal stored in the refresh token.
claimsPrincipal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
}
else
{
throw new InvalidOperationException("The specified grant type is not supported.");
}
// Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
return SignIn(claimsPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)]
[HttpGet("~/connect/userinfo")]
public async Task<IActionResult> Userinfo()
{
var claimsPrincipal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
return Ok(new
{
Sub = claimsPrincipal.GetClaim(OpenIddictConstants.Claims.Subject),
Name = claimsPrincipal.GetClaim(OpenIddictConstants.Claims.Subject),
Occupation = "Developer",
Age = 31
});
}
[HttpPost("~/connect/logout")]
[HttpGet("~/connect/logout")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Logout([FromServices] SignInManager<IdentityUser> signInManager)
{
// Ask ASP.NET Core Identity to delete the local and external cookies created
// when the user agent is redirected from the external identity provider
// after a successful authentication flow (e.g Google or Facebook).
await signInManager.SignOutAsync();
// Returning a SignOutResult will ask OpenIddict to redirect the user agent
// to the post_logout_redirect_uri specified by the client application or to
// the RedirectUri specified in the authentication properties if none was set.
return SignOut(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties
{
RedirectUri = "/"
});
}
}
Nota 👀: Si quieres ver más detalle respecto a Openiddict, visita mi anterior post.
En resumen, esto es lo que estamos permitiendo hacer con este controller y cuando tengamos MultiTenant.Web listo lo podremos probar:
Regular Web App = MulitiTenant.Web
Auth0 Tenant = MultiTenant.IdentityServer
User = Navegador
Your API = Recurso protegido
Flujo:
1) El usuario inicia el proceso de iniciar sesión
2) La aplicación cliente (Web) crea la solicitud de OIDC y redirige al servidor de autenticación
3) Identity Server redirecciona al navegador para que ingrese sus credenciales
4) El usuario se autentica (en este caso, implícitamente al iniciar sesión se da el consentimiento de uso de datos personales, podría hacer un ejemplo después de cómo hacer una ventana de consentimiento)
5) Se regresa un código de autorización, este llega al backend de Web
6) Utilizando el Autorization Code se solicitan los JWTs (Access y Identity) aquí hablo un poco de eso
7) El Identity Server valida que toda la información sea válida y concuerde con la solicitud
8) Regresa los tokens solicitados
9) Utilizando el Access Token, se puede acceder a recursos protegidos
10) Respuesta con información privada del usuario
Verificando OpenID Conect
Verificamos que el discovery endpoint agregado por OpenIddict funcione perfectamente.
Si vemos las llaves de encriptación en https://tenant2.localhost:7143/.well-known/jwks o https://localhost:7143/.well-known/jwks veremos que no van a cambiar, sería un approach muy bueno hacer que estas llaves de encriptación cambien según el tenant. Pero eso ya es para otro tema.
Hasta aquí vamos a la mitad, pero ya hemos realizado la parte más difícil. Lo que resta es hacer la aplicación cliente que a su vez, también será multi-tenant. Espera la segunda parte.
Conclusión
OpenID Connect y aplicaciones SaaS son temas complicados, hacer todo desde cero requere de mucho conocimiento del framework de ASP.NET Core. Afortunadamente existen librerías que nos ayudan a enfocarnos en nuestra aplicación y nos abstraen estos detalles técnicos.
Es importante saber todos estos detalles ténicos, pero también no hay que reinventar la rueda. Si se fijan, en mis posts de multitenancy pasados, hacemos básicamente lo que hace la libería finbuckle, pero esta lo hace mejor, por que un equipo o un par de individuos con mucha experiencia les llevó a realizar esta librería que afortunadamente, es open source.
Cualquier duda, sabes que siempre puedes buscarme en mi twitter como @balunatic.
Referencias
- https://www.finbuckle.com/MultiTenant/Docs/v6.7.3
- https://stackoverflow.com/questions/49596938/openid-connect-identifying-tenant-during-login
- https://documentation.openiddict.com/
- https://github.com/openiddict/openiddict-samples/tree/dev/samples/Velusia
Futuros Posts
- Aplicación Web MultiTenant Cliente de este Identity Server.
- Página de "consent" de acceso a datos.
- Llaves de encriptación según tenant y obtenidas de alguna persistencia.