changes: Handle validation external auth flow

This commit is contained in:
2025-10-14 22:15:59 -06:00
parent a1b917f59c
commit adaa16fbad
5 changed files with 516 additions and 59 deletions

View File

@@ -0,0 +1,219 @@
# External Authentication - Best Practices Implementation
Este documento describe las mejores prácticas implementadas en el sistema de autenticación externa (SSO).
## ✅ Mejoras Implementadas
### 1. **Seguridad**
#### Validación de Tokens Robusta
- ✅ Validación de firma con JWKS
- ✅ Validación de issuer, audience, y lifetime
- ✅ Clock skew configurable para tolerancia de tiempo
- ✅ Manejo específico de excepciones de seguridad
- ✅ Logging detallado de errores de validación sin exponer información sensible
#### Protección de Datos Sensibles
- ✅ Claims sensibles redactados en logs de producción
- ✅ Logs de debug solo habilitados con configuración explícita
- ✅ Secrets encriptados en base de datos
#### Validación de Entrada
- ✅ Validación de modelo en controller
- ✅ Verificación de campos requeridos antes de procesar
### 2. **Arquitectura y Código Limpio**
#### Separation of Concerns
-`TokenAuthController`: Solo manejo de HTTP y orquestación
-`ExternalAuthenticationManager`: Lógica de negocio de autenticación
-`ExternalAuthProviderManager`: Gestión de configuración de providers
#### DRY (Don't Repeat Yourself)
- ✅ Método `CreateClaimsIdentityFromUser` reutilizable para local y external auth
-`GetClaimValue` centraliza lógica de mapeo de claims
- ✅ Constantes en `ExternalAuthConstants` evitan magic strings
#### Código Mantenible
- ✅ Nombres descriptivos y consistentes
- ✅ Documentación XML en métodos públicos
- ✅ Constantes agrupadas lógicamente
- ✅ Mapeo de claims con diccionario extensible
### 3. **Configurabilidad**
#### Settings Flexibles
```
App.ExternalAuth.Enabled
App.ExternalAuth.AutoProvisionUsers
App.ExternalAuth.DefaultRole
App.ExternalAuth.UpdateUserInfoOnLogin
App.ExternalAuth.JwksCacheDurationMinutes
App.ExternalAuth.ClockSkewMinutes
App.ExternalAuth.EnableDebugLogging
```
#### Valores por Defecto Razonables
- JWKS cache: 60 minutos
- Discovery cache: 60 minutos
- Clock skew: 5 minutos
- Token validation timeout: 10 segundos
### 4. **Performance** (Preparado para implementar)
#### Cache Strategy (TODO)
```csharp
// Cache de JWKS para evitar HTTP requests en cada validación
// Cache de OIDC discovery document
// TTL configurable por provider
```
### 5. **Manejo de Errores**
#### Excepciones Específicas
-`SecurityTokenExpiredException` → "Token has expired"
-`SecurityTokenInvalidSignatureException` → "Token signature is invalid"
-`SecurityTokenInvalidIssuerException` → "Token issuer mismatch"
-`SecurityTokenInvalidAudienceException` → "Token audience mismatch"
-`HttpRequestException` → "Unable to connect to provider"
-`JsonException` → "Invalid response from provider"
#### User-Friendly Messages
- Mensajes claros para el usuario
- Detalles técnicos solo en logs del servidor
- No exponer información sensible de configuración
### 6. **Extensibilidad**
#### Soporte Multi-Provider
- Sistema diseñado para múltiples providers simultáneos
- Configuración por provider en base de datos
- Mapeo de claims configurable por provider
#### Claim Mapping Flexible
```csharp
// Standard OIDC claims
ExternalAuthConstants.OidcClaims.*
// Alternative claims (diferentes providers)
ExternalAuthConstants.AlternativeClaims.*
// Mapeo a Microsoft ClaimTypes
StandardToMicrosoftClaimTypeMap
```
### 7. **Auditoría y Compliance**
#### Audit Trail
-`UserLoginAttempt` registra cada intento
-`UserLogin` vincula usuario con provider
- ✅ Información de provider y timestamp
#### Logging Estratégico
- Error logs: Problemas de seguridad y configuración
- Warn logs: Tokens expirados, keys no encontradas
- Debug logs: Claims y flujo detallado (solo desarrollo)
## 🔄 Flujo de Autenticación Externa
```
1. Frontend obtiene ID token de Authentik/Keycloak/etc
2. POST /api/TokenAuth/AuthenticateExternal
- Validación de entrada
3. ExternalAuthenticationManager.AuthenticateWithExternalTokenAsync
- Verificar que external auth esté habilitado
- Obtener configuración del provider
4. ValidateTokenAsync
- Descargar OIDC discovery (.well-known/openid-configuration)
- Descargar JWKS (JSON Web Key Set)
- Validar firma, issuer, audience, lifetime
- Extraer claims
5. FindOrCreateUserAsync
- Buscar UserLogin existente (provider + sub)
- Si no existe y auto-provision habilitado:
* Crear usuario con email del token
* Vincular con provider en UserLogin table
6. CreateClaimsIdentityFromUser
- Construir ClaimsIdentity con datos del usuario local
7. CreateAccessToken
- Generar JWT del backend (NO passthrough)
- Firmar con SecurityKey del backend
- Expiración configurable
8. Retornar token del backend al frontend
- Frontend usa este token para llamadas subsecuentes
- Backend puede validar con su propia clave
```
## 🎯 Próximas Mejoras Sugeridas
### Alta Prioridad
1. **Implementar cache de JWKS y Discovery**
- Usar `ICacheManager` de ABP
- TTL configurable
- Invalidación manual por proveedor
2. **Rate Limiting en endpoint público**
- Prevenir ataques de fuerza bruta
- Configuración por IP o por provider
3. **Actualización de usuario en cada login**
- Implementar `UpdateUserInfoOnLogin` setting
- Sync de email, nombre desde claims del provider
### Media Prioridad
4. **Role Mapping desde Claims**
- Mapear claims de grupos/roles del provider
- Asignar roles de ABP automáticamente
5. **Refresh Token Support**
- Almacenar refresh tokens
- Auto-renovación de sesión
6. **Tests Unitarios**
- Tests para `GetClaimValue`
- Tests para validación de tokens (con tokens mock)
- Tests para JIT provisioning
### Baja Prioridad
7. **Métricas y Monitoreo**
- Contador de logins por provider
- Tiempo de respuesta de validación
- Fallos por tipo de error
8. **Multi-Factor Authentication (MFA)**
- Validar `acr` claim
- Requerir MFA para ciertos roles
## 📚 Referencias
- [OIDC Core Specification](https://openid.net/specs/openid-connect-core-1_0.html)
- [JWT Best Practices](https://tools.ietf.org/html/rfc8725)
- [ASP.NET Core Security](https://learn.microsoft.com/en-us/aspnet/core/security/)
- [ABP Framework Authorization](https://docs.abp.io/en/abp/latest/Authorization)
## 🔐 Consideraciones de Seguridad
### Producción
- ⚠️ SIEMPRE usar HTTPS
- ⚠️ `EnableDebugLogging = false`
- ⚠️ Rotar `SecurityKey` regularmente
- ⚠️ Configurar `RequireHttpsMetadata = true` en providers
- ⚠️ Monitorear logs de intentos fallidos
### Desarrollo
- ✅ Usar providers de test separados
- ✅ No exponer client secrets en repositorio
- ✅ Usar variables de entorno o secret managers
---
**Última actualización**: 2025-10-15
**Versión**: 1.0
**Implementado por**: JJSolutions - SSO Implementation Team

View File

@@ -0,0 +1,58 @@
namespace ASPBaseOIDC.Authorization.ExternalAuth;
/// <summary>
/// Constants for external authentication
/// </summary>
public static class ExternalAuthConstants
{
/// <summary>
/// Standard OIDC claim types
/// </summary>
public static class OidcClaims
{
public const string Sub = "sub";
public const string Email = "email";
public const string EmailVerified = "email_verified";
public const string Name = "name";
public const string GivenName = "given_name";
public const string FamilyName = "family_name";
public const string PreferredUsername = "preferred_username";
public const string Nickname = "nickname";
public const string Picture = "picture";
public const string Locale = "locale";
public const string UpdatedAt = "updated_at";
}
/// <summary>
/// Alternative claim types used by different providers
/// </summary>
public static class AlternativeClaims
{
public const string Uid = "uid";
public const string UserId = "user_id";
public const string Username = "username";
public const string UserPrincipalName = "upn";
}
/// <summary>
/// Cache keys for external auth
/// </summary>
public static class CacheKeys
{
public const string JwksPrefix = "ExternalAuth:Jwks:";
public const string OidcDiscoveryPrefix = "ExternalAuth:Discovery:";
public const string EnabledProviders = "ExternalAuth:EnabledProviders";
}
/// <summary>
/// Default configuration values
/// </summary>
public static class Defaults
{
public const int JwksCacheDurationMinutes = 60;
public const int DiscoveryCacheDurationMinutes = 60;
public const int TokenValidationTimeoutSeconds = 10;
public const int ClockSkewMinutes = 5;
public const int MaxLoginAttemptAgeMinutes = 15;
}
}

View File

@@ -83,23 +83,29 @@ public class ExternalAuthenticationManager : DomainService
// 3. Validate JWT token
var claims = await ValidateTokenAsync(provider, idToken);
// Log all claims for debugging
Logger.Info("Claims received from token:");
foreach (var claim in claims)
// Log all claims for debugging (only in Debug mode)
if (Logger.IsDebugEnabled)
{
Logger.Info($" {claim.Type} = {claim.Value}");
Logger.Debug($"Claims received from {provider.Name} token:");
foreach (var claim in claims)
{
// Don't log sensitive values in production
var value = claim.Type.Contains("token", StringComparison.OrdinalIgnoreCase)
? "***REDACTED***"
: claim.Value;
Logger.Debug($" {claim.Type} = {value}");
}
}
// Try to find subject identifier (sub, uid, user_id, etc.)
var sub = claims.FirstOrDefault(c => c.Type == "sub")?.Value
?? claims.FirstOrDefault(c => c.Type == "uid")?.Value
?? claims.FirstOrDefault(c => c.Type == "user_id")?.Value
// Try to find subject identifier using constants
var sub = claims.FirstOrDefault(c => c.Type == ExternalAuthConstants.OidcClaims.Sub)?.Value
?? claims.FirstOrDefault(c => c.Type == ExternalAuthConstants.AlternativeClaims.Uid)?.Value
?? claims.FirstOrDefault(c => c.Type == ExternalAuthConstants.AlternativeClaims.UserId)?.Value
?? claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;
// Try to find email using multiple formats
var email = claims.FirstOrDefault(c => c.Type == "email")?.Value
?? claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value
?? claims.FirstOrDefault(c => c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress")?.Value;
var email = claims.FirstOrDefault(c => c.Type == ExternalAuthConstants.OidcClaims.Email)?.Value
?? claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(sub))
{
@@ -152,6 +158,22 @@ public class ExternalAuthenticationManager : DomainService
// Download JWKS
var jwksResponse = await httpClient.GetStringAsync(jwksUri);
// Get configurable clock skew (default: 5 minutes)
var clockSkewMinutes = ExternalAuthConstants.Defaults.ClockSkewMinutes;
try
{
var clockSkewSetting = await _settingManager.GetSettingValueForApplicationAsync(
AppSettingNames.ExternalAuth.ClockSkewMinutes);
if (int.TryParse(clockSkewSetting, out var parsedValue) && parsedValue > 0)
{
clockSkewMinutes = parsedValue;
}
}
catch
{
// Setting doesn't exist, use default
}
// Create validation parameters
var validationParameters = new TokenValidationParameters
{
@@ -163,40 +185,100 @@ public class ExternalAuthenticationManager : DomainService
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
{
var jwks = JsonDocument.Parse(jwksResponse);
var keys = jwks.RootElement.GetProperty("keys");
var signingKeys = new List<SecurityKey>();
foreach (var key in keys.EnumerateArray())
try
{
var keyId = key.GetProperty("kid").GetString();
if (keyId == kid)
var jwks = JsonDocument.Parse(jwksResponse);
if (!jwks.RootElement.TryGetProperty("keys", out var keysElement))
{
var e = key.GetProperty("e").GetString();
var n = key.GetProperty("n").GetString();
var rsa = new RsaSecurityKey(new System.Security.Cryptography.RSAParameters
{
Exponent = Base64UrlEncoder.DecodeBytes(e),
Modulus = Base64UrlEncoder.DecodeBytes(n)
});
signingKeys.Add(rsa);
Logger.Error("Invalid JWKS response: 'keys' property not found");
return Array.Empty<SecurityKey>();
}
}
return signingKeys;
var signingKeys = new List<SecurityKey>();
foreach (var key in keysElement.EnumerateArray())
{
if (!key.TryGetProperty("kid", out var kidProperty))
{
continue;
}
var keyId = kidProperty.GetString();
if (keyId == kid)
{
if (key.TryGetProperty("kty", out var kty) && kty.GetString() == "RSA")
{
var e = key.GetProperty("e").GetString();
var n = key.GetProperty("n").GetString();
var rsa = new RsaSecurityKey(new System.Security.Cryptography.RSAParameters
{
Exponent = Base64UrlEncoder.DecodeBytes(e),
Modulus = Base64UrlEncoder.DecodeBytes(n)
})
{
KeyId = keyId
};
signingKeys.Add(rsa);
}
}
}
if (signingKeys.Count == 0)
{
Logger.Warn($"No matching signing key found for kid: {kid}");
}
return signingKeys;
}
catch (Exception ex)
{
Logger.Error($"Error parsing JWKS: {ex.Message}", ex);
return Array.Empty<SecurityKey>();
}
},
ClockSkew = TimeSpan.FromMinutes(5)
ClockSkew = TimeSpan.FromMinutes(clockSkewMinutes)
};
// Validate token
var principal = handler.ValidateToken(idToken, validationParameters, out var validatedToken);
return principal.Claims.ToList();
}
catch (SecurityTokenExpiredException ex)
{
Logger.Warn($"Token expired for provider {provider.Name}: {ex.Message}");
throw new UserFriendlyException("Token has expired. Please sign in again.");
}
catch (SecurityTokenInvalidSignatureException ex)
{
Logger.Error($"Token signature validation failed for provider {provider.Name}: {ex.Message}", ex);
throw new UserFriendlyException("Token signature is invalid. This may indicate a security issue.");
}
catch (SecurityTokenInvalidIssuerException ex)
{
Logger.Error($"Token issuer validation failed for provider {provider.Name}. Expected: {provider.Authority}, Got: {ex.InvalidIssuer}", ex);
throw new UserFriendlyException($"Token issuer mismatch. Please check provider configuration.");
}
catch (SecurityTokenInvalidAudienceException ex)
{
Logger.Error($"Token audience validation failed for provider {provider.Name}. Expected: {provider.ClientId}", ex);
throw new UserFriendlyException("Token audience mismatch. Please check provider configuration.");
}
catch (HttpRequestException ex)
{
Logger.Error($"Failed to reach OIDC endpoints for provider {provider.Name}: {ex.Message}", ex);
throw new UserFriendlyException("Unable to connect to authentication provider. Please try again later.");
}
catch (JsonException ex)
{
Logger.Error($"Failed to parse OIDC response for provider {provider.Name}: {ex.Message}", ex);
throw new UserFriendlyException("Invalid response from authentication provider.");
}
catch (Exception ex)
{
Logger.Error("Token validation failed", ex);
throw new UserFriendlyException("Invalid token: " + ex.Message);
Logger.Error($"Unexpected error during token validation for provider {provider.Name}: {ex.Message}", ex);
throw new UserFriendlyException("An error occurred during authentication. Please try again.");
}
}
@@ -347,30 +429,55 @@ public class ExternalAuthenticationManager : DomainService
await CurrentUnitOfWork.SaveChangesAsync();
}
/// <summary>
/// Get claim value with fallback to standard Microsoft claim types
/// </summary>
private string GetClaimValue(List<Claim> claims, Dictionary<string, string> mappings, string claimType)
{
if (!mappings.TryGetValue(claimType, out var mappedType))
// Try custom mapping first
if (mappings.TryGetValue(claimType, out var mappedType))
{
mappedType = claimType;
var value = claims.FirstOrDefault(c => c.Type == mappedType)?.Value;
if (!string.IsNullOrEmpty(value))
{
return value;
}
}
// Try to find the claim by mapped type or standard type
var value = claims.FirstOrDefault(c => c.Type == mappedType)?.Value;
if (!string.IsNullOrEmpty(value))
// Try standard OIDC claim type
var standardValue = claims.FirstOrDefault(c => c.Type == claimType)?.Value;
if (!string.IsNullOrEmpty(standardValue))
{
return value;
return standardValue;
}
// Fallback: Try common Microsoft claim type mappings
return claimType switch
// Fallback: Try Microsoft .NET claim type equivalents
return GetMicrosoftClaimTypeFallback(claims, claimType);
}
/// <summary>
/// Maps OIDC standard claims to Microsoft .NET ClaimTypes
/// </summary>
private static readonly Dictionary<string, string> StandardToMicrosoftClaimTypeMap = new()
{
{ ExternalAuthConstants.OidcClaims.Email, ClaimTypes.Email },
{ ExternalAuthConstants.OidcClaims.Name, ClaimTypes.Name },
{ ExternalAuthConstants.OidcClaims.GivenName, ClaimTypes.GivenName },
{ ExternalAuthConstants.OidcClaims.FamilyName, ClaimTypes.Surname },
{ ExternalAuthConstants.OidcClaims.PreferredUsername, ClaimTypes.Name },
};
/// <summary>
/// Fallback to Microsoft claim types when standard OIDC claims not found
/// </summary>
private string GetMicrosoftClaimTypeFallback(List<Claim> claims, string claimType)
{
if (StandardToMicrosoftClaimTypeMap.TryGetValue(claimType, out var microsoftType))
{
"email" => claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value,
"name" => claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value,
"given_name" => claims.FirstOrDefault(c => c.Type == ClaimTypes.GivenName)?.Value,
"family_name" => claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)?.Value,
"preferred_username" => claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value,
_ => null
};
return claims.FirstOrDefault(c => c.Type == microsoftType)?.Value;
}
return null;
}
private int GetTokenExpiration(string idToken)

View File

@@ -28,6 +28,37 @@ public static class AppSettingNames
/// Default role name for auto-provisioned users (empty = no role assigned)
/// </summary>
public const string DefaultRole = "App.ExternalAuth.DefaultRole";
/// <summary>
/// Update user information from external provider on each login
/// </summary>
public const string UpdateUserInfoOnLogin = "App.ExternalAuth.UpdateUserInfoOnLogin";
/// <summary>
/// Cache duration for JWKS keys in minutes (default: 60)
/// </summary>
public const string JwksCacheDurationMinutes = "App.ExternalAuth.JwksCacheDurationMinutes";
/// <summary>
/// Cache duration for OIDC discovery document in minutes (default: 60)
/// </summary>
public const string DiscoveryCacheDurationMinutes = "App.ExternalAuth.DiscoveryCacheDurationMinutes";
/// <summary>
/// Token validation timeout in seconds (default: 10)
/// </summary>
public const string TokenValidationTimeoutSeconds = "App.ExternalAuth.TokenValidationTimeoutSeconds";
/// <summary>
/// Clock skew tolerance for token validation in minutes (default: 5)
/// </summary>
public const string ClockSkewMinutes = "App.ExternalAuth.ClockSkewMinutes";
/// <summary>
/// Enable debug logging for external authentication (default: false)
/// WARNING: May log sensitive information, only enable in development
/// </summary>
public const string EnableDebugLogging = "App.ExternalAuth.EnableDebugLogging";
}
}

View File

@@ -2,6 +2,7 @@
using Abp.Authorization.Users;
using Abp.MultiTenancy;
using Abp.Runtime.Security;
using Abp.UI;
using ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
using ASPBaseOIDC.Authentication.JwtBearer;
using ASPBaseOIDC.Authorization;
@@ -70,6 +71,17 @@ namespace ASPBaseOIDC.Controllers
[AbpAllowAnonymous]
public async Task<AuthenticateResultModel> AuthenticateExternal([FromBody] ExternalAuthModel model)
{
// Validate input
if (string.IsNullOrWhiteSpace(model?.ProviderName))
{
throw new UserFriendlyException("Provider name is required");
}
if (string.IsNullOrWhiteSpace(model.IdToken))
{
throw new UserFriendlyException("ID token is required");
}
// Authenticate with external provider (validates token, provisions user if needed)
var result = await _externalAuthManager.AuthenticateWithExternalTokenAsync(
model.ProviderName,
@@ -77,24 +89,15 @@ namespace ASPBaseOIDC.Controllers
AbpSession.TenantId
);
// Create claims identity for the user (same as local login)
var identity = new ClaimsIdentity();
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, result.User.Id.ToString()));
identity.AddClaim(new Claim(ClaimTypes.Name, result.User.UserName));
identity.AddClaim(new Claim(ClaimTypes.Email, result.User.EmailAddress));
// Add tenant claim if applicable
if (result.User.TenantId.HasValue)
{
identity.AddClaim(new Claim(AbpClaimTypes.TenantId, result.User.TenantId.Value.ToString()));
}
// Create claims identity for the authenticated user
var identity = CreateClaimsIdentityFromUser(result.User);
// Generate backend's own JWT token
var accessToken = CreateAccessToken(CreateJwtClaims(identity));
return new AuthenticateResultModel
{
AccessToken = accessToken, // Backend's own JWT
AccessToken = accessToken,
EncryptedAccessToken = GetEncryptedAccessToken(accessToken),
ExpireInSeconds = (int)_configuration.Expiration.TotalSeconds,
UserId = result.User.Id
@@ -160,6 +163,45 @@ namespace ASPBaseOIDC.Controllers
{
return SimpleStringCipher.Instance.Encrypt(accessToken);
}
/// <summary>
/// Creates a ClaimsIdentity from a User entity
/// Centralizes claim creation logic for both local and external authentication
/// </summary>
private ClaimsIdentity CreateClaimsIdentityFromUser(User user)
{
var identity = new ClaimsIdentity();
// Standard claims
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
identity.AddClaim(new Claim(ClaimTypes.Name, user.UserName));
if (!string.IsNullOrEmpty(user.EmailAddress))
{
identity.AddClaim(new Claim(ClaimTypes.Email, user.EmailAddress));
}
if (!string.IsNullOrEmpty(user.Name))
{
identity.AddClaim(new Claim(ClaimTypes.GivenName, user.Name));
}
if (!string.IsNullOrEmpty(user.Surname))
{
identity.AddClaim(new Claim(ClaimTypes.Surname, user.Surname));
}
// Tenant claim
if (user.TenantId.HasValue)
{
identity.AddClaim(new Claim(AbpClaimTypes.TenantId, user.TenantId.Value.ToString()));
}
// Note: Roles and permissions will be added by ABP authorization system
// when user makes authorized requests
return identity;
}
}
}