Introducción
En este post continuaremos con la creación de aplicaciones web multi-tenant, enfocándonos ahora en la parte de autenticación del cliente.
Crear aplicaciones SaaS suele ser complicado, pero he estado trabajando con esta librería llamada Finbuckle.MultiTenant que facilita el trabajo demasiado.
Ya hemos hablado de estos temas a detalle en esta serie de artículos, donde vemos las distintas formas de crear aplicaciones multi-tenant y hacer una implementación de forma manual (si te interesa hacerlo todo desde 0, te recomiendo esa serie).
Nota: Antes de que lo olvide, aquí puedes ver el código fuente que estaremos viendo en este post.
Lo que estamos realizando en estos dos posts es lo siguiente:
La idea es la siguiente:
- Tener un IdentityServer que autentique a los clientes utilizando Open ID Connect.
- El IdentityServer lo hemos construido aquí.
- La sección My Services (private data) consiste de información privada de usuarios, el cual, por medio de OIDC se podrá dar acceso a sistemas terceros para que estos puedan accederla.
- La idea de Protected API es tener una REST API el cual contiene esa información privada, se accede por medio de Bearer Tokens emitidos por el Identity Server.
- En External Clients crearemos en este post solamente el ejemplo Web Client
- Por medio de OIDC se autenticará y obtendrán Json Web Tokens para poder hablar con Protected API
Nota: Protected API no existe por ahora, podemos crearla bien fácil en un siguiente post
MultiTenant.Web
En el post pasado vimos cómo crear un servidor de autenticación multi-tenant. Este servicio incluye una base de datos "maestra", que es la que guarda los registros de los distintos Tenants que existen, y también la base de datos de la aplicación en si (en donde se guardan los usuarios, los clientes OIDC, etc de cada tenant).
En este post, veremos cómo crear una Aplicación Web cliente que también será multi-tenant. Esta aplicación web utilizará el servidor de autenticación para el manejo de usuarios, pero esta a su vez, será multi-tenant.
En este ejemplo yo decidí que este proyecto tuviera su propia base de datos de manejo de tenants (la "maestra") porque lo estoy pensando como si fuera algo totalmente independiente. Pero de lo contrario, la base de datos que contiene los Tenants se podría compartir entre los dos proyectos.
En resumen, tendremos ahora una aplicación Web Multi-tenant, en donde podremos configurar por cada tenant, su información de OpenID Connect. Es decir, cada tenant podrá tener su proveedor de identidad, de hecho, sin ningún problema, para un tenant podemos poner Azure AD, o Auth0 o nuestro propio proveedor de identidad (que es el propósito de este ejemplo, pero sin problema podemos extender esta aplicación para que utilicé, como ejemplo Auth0).
Para comenzar, hay que crear un proyecto Razor para evitarnos un poco de trabajo con el UI (con dotnet new razor
sin autenticación). También utilizaremos los siguientes paquetes, muy similar a los usados en la parte 1, solo que aquí no usamos nada de Openiddict y usamos Microsoft.AspNetCore.Authentication.OpenIdConnect
como implementación cliente de OIDC.
<PackageReference Include="Finbuckle.MultiTenant.AspNetCore" Version="6.7.3" />
<PackageReference Include="Finbuckle.MultiTenant.EntityFrameworkCore" Version="6.7.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Domain
Esta aplicación web cliente, tendrá como ejemplo un listado de productos, solo para volver a ilustrar como queda segmentada la información por tenant. Realmente esto ya no es relevante para este post, lo que importa es la configuración de OpenID Connect, pero igual lo menciono.
Nota 💡: Como siempre, aquí puedes encontrar el código fuente
Domain > Entities > Product
namespace MultiTenants.Web.Domain.Entities;
public class Product
{
public int ProductId { get; set; }
public string Description { get; set; } = default!;
}
Domain > Entities > TenantAdmin > MultiTenantInfo
Igual que en la parte 1, necesitamos guardar la información de cada tenant:
using Finbuckle.MultiTenant;
namespace MultiTenants.Web.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; }
public string OpenIdClientId { get; set; }
public string OpenIdClientSecret { get; set; }
public string OpenIdAuthority { get; set; }
}
Nota 💡: Para aprender más sobre aplicaciones multi-tenant, puedes revisar esta serie de posts que ya he escrito sobre este tema a más profundidad.
Persistence
Como lo he mencionado anteriormente, tendremos nuevamente dos esquemas de bases de datos: La de la aplicación y la de los Tenants.
Como ilustración, visualizamos la idea general de estas dos aplicaciones. Totalmente independientes, cada una cuenta con sus contextos de bases de datos. IdentityServerDbContext
y MyDbContext
son las bases de datos que crecerán según el número de tenants. Esto es siguiendo el approach de una base de datos por cada tenant, esto puede cambiar según las necesidades sin ningún problema (como una base de datos para todos los tenants).
Persistence > TenantAdminDbContext
Heredando del contexto prestablecido por Finbuckle, lo creamos así:
using Finbuckle.MultiTenant.Stores;
using Microsoft.EntityFrameworkCore;
using MultiTenants.Web.Domain.Entities.TenantAdmin;
namespace MultiTenants.Web.Persistence;
public class TenantAdminDbContext : EFCoreStoreDbContext<MultiTenantInfo>
{
public TenantAdminDbContext(DbContextOptions<TenantAdminDbContext> options)
: base(options)
{
}
}
Persistence > MyDbContext
Este DbContext
realmente lo puedes hacer como lo necesites, aquí la idea es que esta sea la base de datos de la aplicación web cliente. Lo que sea que haga tu aplicación aquí irá.
using Finbuckle.MultiTenant;
using Microsoft.EntityFrameworkCore;
using MultiTenants.Web.Domain.Entities;
using MultiTenants.Web.Domain.Entities.TenantAdmin;
namespace MultiTenants.Web.Persistence;
public class MyDbContext : DbContext
{
private readonly IMultiTenantContextAccessor<MultiTenantInfo> _tenantAccessor;
private readonly IWebHostEnvironment _env;
private readonly IConfiguration _config;
public MyDbContext(
DbContextOptions<MyDbContext> options,
IMultiTenantContextAccessor<MultiTenantInfo> tenantAccessor,
IWebHostEnvironment env,
IConfiguration config) : base(options)
{
_tenantAccessor = tenantAccessor;
_env = env;
_config = config;
}
public DbSet<Product> Products => Set<Product>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
var connectionString = string.Empty;
if (_tenantAccessor.MultiTenantContext is null && _env.IsDevelopment())
{
connectionString = _config.GetConnectionString("Default");
}
else
{
connectionString = _tenantAccessor.MultiTenantContext.TenantInfo.ConnectionString;
}
optionsBuilder.UseSqlServer(connectionString);
}
}
Nota 💡: Aunque esta puede ser aplicación cliente desarrollada por ti, podría ser desarrollada por algún tercero, el cual tú quieres abrir el acceso de información privada de tus usuarios (protegidos por el Identity Server).
Es usual tener Web APIs que den acceso a esa información por medio de JWTs y Identity Tokens, aquí puedes leer más sobre el tema.
Program.cs
La inicialización de todo el proyecto lo hacemos ver muy simple porque me gusta usar métodos de extensión, así queda más legible:
using Finbuckle.MultiTenant;
using MultiTenants.Web;
using MultiTenants.Web.Domain.Entities.TenantAdmin;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddDbContexts(builder.Configuration.GetConnectionString("TenantAdmin"));
builder.Services.AddMuiltiTenantSupport();
builder.Services.AddAuthentications();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseMultiTenant();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
await SetupStore(app.Services);
app.Run();
static async Task SetupStore(IServiceProvider sp)
{
var scopeServices = sp.CreateScope().ServiceProvider;
var store = scopeServices.GetRequiredService<IMultiTenantStore<MultiTenantInfo>>();
var tenants = await store.GetAllAsync();
if (tenants.Count() > 0)
{
return;
}
await store.TryAddAsync(new MultiTenantInfo
{
Id = "tenant-1",
Identifier = "localhost",
Name = "My Dev Tenant",
ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=AspNetCoreMultiTenantOpenId_DevTenant;Trusted_Connection=True;MultipleActiveResultSets=true",
OpenIdClientId = "tenant01",
OpenIdClientSecret = "tenant01-web-app-secret",
OpenIdAuthority = "https://localhost:7193"
});
await store.TryAddAsync(new MultiTenantInfo
{
Id = "tenant-2",
Identifier = "tenant2.localhost",
Name = "My Dev Tenant 2",
ConnectionString = "Server=(localdb)\\mssqllocaldb;Database=AspNetCoreMultiTenantOpenId_DevTenant02;Trusted_Connection=True;MultipleActiveResultSets=true",
OpenIdClientId = "tenant02",
OpenIdClientSecret = "tenant02-web-app-secret",
OpenIdAuthority = "https://tenant2.localhost:7193"
});
}
SetupStore
inicializa dos tenants, estos son idénticos a los que tenemos en el IdentityServer, por eso menciono que podríamos usar una sola base de datos si lo deseamos.
Aquí es importante mencionar que la información de OpenID que se guarda en MultiTenantInfo
es exactamente igual en como está registrada en el Identity Server.
La idea aquí, es que cada Tenant tenga su propia "instancia" del servidor de autenticación, para que sus usuarios sean realmente independientes y aislados, pues de hecho, así debe de ser en una aplicación SaaS.
DependencyConfig.cs
Aquí definimos los métodos de extensión usados anteriormente:
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using MultiTenants.Web.Domain.Entities.TenantAdmin;
using MultiTenants.Web.Persistence;
namespace MultiTenants.Web;
public static class DependencyConfig
{
public static IServiceCollection AddAuthentications(this IServiceCollection services)
{
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect();
return services;
}
public static IServiceCollection AddDbContexts(this IServiceCollection services, string connectionString)
{
services.AddDbContext<TenantAdminDbContext>(options =>
options.UseSqlServer(connectionString));
services.AddDbContext<MyDbContext>();
return services;
}
public static IServiceCollection AddMuiltiTenantSupport(this IServiceCollection services)
{
services.AddMultiTenant<MultiTenantInfo>()
.WithHostStrategy()
.WithEFCoreStore<TenantAdminDbContext, MultiTenantInfo>()
.WithPerTenantAuthentication()
.WithPerTenantOptions<CookieAuthenticationOptions>((options, tenant) =>
{
options.Cookie.Name = $".Auth{tenant.Id}";
})
.WithPerTenantOptions<OpenIdConnectOptions>((options, tenant) =>
{
options.Scope.Add("openid");
options.Scope.Add("profile");
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters.NameClaimType = "name";
options.ResponseType = OpenIdConnectResponseType.Code;
options.Authority = tenant.OpenIdAuthority;
options.ClientId = tenant.OpenIdClientId;
options.ClientSecret = tenant.OpenIdClientSecret;
});
return services;
}
}
-
AddDbContexts
: Agregamos dos contextos, uno por cada base de datos ya mencionada anteriormente. -
AddMultiTenantSupport
: La magia multi-tenant.- Hacemos que nuestra aplicación sea multi-tenant según el host (ejem. tenant1.example.com, tenant2.example.com).
- Le decimos a Finbuckle que guarde cada tenant en una BD usando EF Core.
- Especificamos que cada tenant tendrá su configuración de autenticación.
- Se configura la autenticación por cookies. Aquí haremos que el nombre de la cookie de autenticación sea diferente según el tenant, así hacemos que las cookies no se mezclen entre tenants.
- Se configura la autenticación Open ID Connect por tenant.
- Aquí es donde hacemos que cada tenant tenga su propio proveedor de OIDC
-
AddWebAuthentication
: Especificamos los dos esquemas de autenticación que manejaremos; Por Cookies y OpenID Connect.- En esta parte, si te das cuenta no configuramos nada en lo absoluto, la idea es que suceda en el punto anterior y sea configuración según el tenant.
De esta forma ya tendremos la aplicación web con soporte multi-tenant y con autenticación según el tenant.
En AddMultiTenantSupport
es donde podemos agregar cualquier proveedor de identidad que utilice OpenID Connect. En este caso (visto en el Program.cs) configuraremos el mismo proveedor de identidad, pero con distintos clientes.
Así cada tenant tendrá su propia base de datos de usuarios, si tenant1 inicia sesión y luego se mete a tenant2, tendrá que iniciar sesión (y con otro usuario, ya que no se comparten).
Root > Index.cshtml
Las vistas serán sencillas, simplemente para visualizar un poco lo que está sucediendo:
@page
@using Microsoft.AspNetCore.Authentication
@using MultiTenants.Web.Domain.Entities.TenantAdmin
@model IndexModel
@inject Finbuckle.MultiTenant.IMultiTenantContextAccessor<MultiTenantInfo> TenantAccessor
@{
ViewData["Title"] = "Tenant " + TenantAccessor.MultiTenantContext.TenantInfo.Name;
}
<h1>@TenantAccessor.MultiTenantContext.TenantInfo.Name</h1>
@if(User.Identity!.IsAuthenticated)
{
<h2>Welcome @User.Identity.Name</h2>
<ul>
@foreach(var claim in @User.Claims)
{
<li>@claim.Type: @claim.Value</li>
}
<li>access_token: @(await HttpContext.GetTokenAsync("access_token"))</li>
<li>id_token: @(await HttpContext.GetTokenAsync("id_token"))</li>
</ul>
}
else
{
<span>Anonymous User</span>
}
Root > Account.cshtml.cs
Utilizamos este Razor Page para poder disparar los procesos de iniciar sesión y de cerrar sesión:
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MultiTenants.Web.Pages
{
public class AccountModel : PageModel
{
public void OnGet()
{
}
public IActionResult OnGetLogIn()
{
return Challenge(new AuthenticationProperties { RedirectUri = "/" }, OpenIdConnectDefaults.AuthenticationScheme);
}
public IActionResult OnPostSignOut()
{
return SignOut(
new AuthenticationProperties { RedirectUri = "/" },
CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme);
}
}
}
Shared > LoginPartial.cshtml
Desde este _LoginPartial.cshtml
mostramos si el usuario inició sesión y su botón para cerrar sesión (utilizando el Razor Page anterior). Si no ha iniciado sesión, mostramos el botón de iniciar sesión y este nos redireccionará automáticamente al proveedor de identidad (en este caso, el IdentityServer, aunque puede ser Auth0 o Azure AD).
<ul class="navbar-nav">
@if (User.Identity.IsAuthenticated)
{
<li class="nav-item">
<a class="nav-link text-dark" >Hello @User.Identity?.Name!</a>
</li>
<li class="nav-item">
<form class="form-inline" asp-page="/Account" asp-page-handler="SignOut" 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-page="/Account" asp-page-handler="LogIn">Login</a>
</li>
}
</ul>
Pages > Products > Index
Aquí creamos un simple listado de productos, pero esta vista requiere de usuarios autenticados, por lo que, si intentamos entrar, automáticamente nos redireccionará a la vista de autenticación de nuestro proveedor (según se configuró en los esquemas de autenticación, o sea, OIDC).
@page
@model IndexModel
<h1>Productos</h1>
<table class="table">
<thead>
<tr>
<th>Product Id</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@foreach (var product in Model.Products)
{
<tr>
<td>@product.ProductId</td>
<td>@product.Description</td>
</tr>
}
</tbody>
</table>
Este DbContext lo hemos hecho dinámico según el tenant, esto todo gracias a la librería Finbuckle. Por lo que simplemente al solicitar el DbContext como dependencia, este nos conectará automáticamente a la base de datos correspondiente.
Esto lo hemos visto en la serie de posts multi-tenant, pero en esencia, es eso.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MultiTenants.Web.Domain.Entities;
using MultiTenants.Web.Persistence;
namespace MultiTenants.Web.Pages.Products;
[Authorize]
public class IndexModel : PageModel
{
private readonly MyDbContext _context;
public IndexModel(MyDbContext context)
{
_context = context;
}
public ICollection<Product> Products { get; set; } = new List<Product>();
public async Task OnGet()
{
Products = await _context.Products.ToListAsync();
}
}
Probando la solución
Para poder probar todo lo que estamos haciendo, hay que correr las dos aplicaciones web, tanto el Identity Server y el Web.
Web
Si entramos al menú de productos dentro de Web, este nos mandará automaticamente al servidor de autenticación configurado (utilizando los clientes también configurados para ese tenant).
Al iniciar sesión (tal vez, primero necesitamos crear un usuario) podremos ver ya la vista de productos (la vista protegida de Web).
En este caso está vacía, por que pues, hay que crear productos de prueba jaja.
Conclusión
Hemos realizado una aplicación web multi-tenant, con autenticación independiente y configurada según el tenant.
Es decir, cada tenant puede tener su propio proveedor de identidad, ya sea Azure AD B2C, Auth0, Google o hasta facebook.
Gracias al Open Source y la gran cantidad de librerías que podemos usar, ahora realizar este tipo de software ya no es tan complicado. Pero como siempre, si tienes dudas o algún problema, con gusto me puedes buscar en mi twitter.