feature: Added External Athentication base
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
|
||||
|
||||
public class CreateOrUpdateProviderInput
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string ProviderType { get; set; }
|
||||
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(512)]
|
||||
public string Authority { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string ClientId { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(512)]
|
||||
public string ClientSecret { get; set; }
|
||||
|
||||
[MaxLength(512)]
|
||||
public string Scopes { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string ResponseType { get; set; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; }
|
||||
|
||||
[MaxLength(2048)]
|
||||
public string ClaimMappings { get; set; }
|
||||
|
||||
public int DisplayOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// Model for external authentication request
|
||||
/// </summary>
|
||||
public class ExternalAuthModel
|
||||
{
|
||||
[Required]
|
||||
public string ProviderName { get; set; }
|
||||
|
||||
[Required]
|
||||
public string IdToken { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Abp.Application.Services.Dto;
|
||||
using Abp.AutoMapper;
|
||||
using ASPBaseOIDC.Authorization.ExternalAuth;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
|
||||
|
||||
[AutoMapFrom(typeof(ExternalAuthProvider))]
|
||||
[AutoMapTo(typeof(ExternalAuthProvider))]
|
||||
public class ExternalAuthProviderDto : EntityDto<int>
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string ProviderType { get; set; }
|
||||
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(512)]
|
||||
public string Authority { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(256)]
|
||||
public string ClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Client secret (masked when returning to client, full when creating/updating)
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
public string ClientSecret { get; set; }
|
||||
|
||||
[MaxLength(512)]
|
||||
public string Scopes { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string ResponseType { get; set; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; }
|
||||
|
||||
[MaxLength(2048)]
|
||||
public string ClaimMappings { get; set; }
|
||||
|
||||
public int DisplayOrder { get; set; }
|
||||
|
||||
public int? TenantId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
|
||||
|
||||
public class TestConnectionOutput
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using Abp.Application.Services;
|
||||
using Abp.Authorization;
|
||||
using Abp.Domain.Repositories;
|
||||
using Abp.UI;
|
||||
using ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
|
||||
using ASPBaseOIDC.Authorization;
|
||||
using ASPBaseOIDC.Authorization.ExternalAuth;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ASPBaseOIDC.Application.Authorization.ExternalAuth;
|
||||
|
||||
/// <summary>
|
||||
/// Application service for managing external authentication providers
|
||||
/// Requires admin permissions
|
||||
/// </summary>
|
||||
[AbpAuthorize(PermissionNames.Pages_Users)] // TODO: Create specific permission for external auth
|
||||
public class ExternalAuthProviderAppService : ApplicationService
|
||||
{
|
||||
private readonly ExternalAuthProviderManager _providerManager;
|
||||
private readonly IRepository<ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider> _providerRepository;
|
||||
|
||||
public ExternalAuthProviderAppService(
|
||||
ExternalAuthProviderManager providerManager,
|
||||
IRepository<ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider> providerRepository)
|
||||
{
|
||||
_providerManager = providerManager;
|
||||
_providerRepository = providerRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all providers (with secrets masked)
|
||||
/// </summary>
|
||||
public virtual async Task<List<ExternalAuthProviderDto>> GetAllProviders()
|
||||
{
|
||||
var providers = await _providerManager.GetAllAsync();
|
||||
|
||||
return providers.Select(p => new ExternalAuthProviderDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
ProviderType = p.ProviderType,
|
||||
IsEnabled = p.IsEnabled,
|
||||
Authority = p.Authority,
|
||||
ClientId = p.ClientId,
|
||||
ClientSecret = MaskSecret(p.ClientSecret), // Mask secret
|
||||
Scopes = p.Scopes,
|
||||
ResponseType = p.ResponseType,
|
||||
RequireHttpsMetadata = p.RequireHttpsMetadata,
|
||||
ClaimMappings = p.ClaimMappings,
|
||||
DisplayOrder = p.DisplayOrder,
|
||||
TenantId = p.TenantId
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get enabled providers for login page (public - no auth required)
|
||||
/// </summary>
|
||||
[AbpAllowAnonymous]
|
||||
public virtual async Task<List<ExternalAuthProviderDto>> GetEnabledProviders()
|
||||
{
|
||||
var providers = await _providerManager.GetEnabledProvidersAsync();
|
||||
|
||||
return providers.Select(p => new ExternalAuthProviderDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
ProviderType = p.ProviderType,
|
||||
Authority = p.Authority,
|
||||
ClientId = p.ClientId,
|
||||
ClientSecret = null, // Never expose secret publicly
|
||||
Scopes = p.Scopes,
|
||||
ResponseType = p.ResponseType,
|
||||
RequireHttpsMetadata = p.RequireHttpsMetadata,
|
||||
DisplayOrder = p.DisplayOrder
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get provider by ID (with secret masked)
|
||||
/// </summary>
|
||||
public virtual async Task<ExternalAuthProviderDto> GetProvider(int id)
|
||||
{
|
||||
var provider = await _providerManager.GetByIdAsync(id);
|
||||
|
||||
return new ExternalAuthProviderDto
|
||||
{
|
||||
Id = provider.Id,
|
||||
Name = provider.Name,
|
||||
ProviderType = provider.ProviderType,
|
||||
IsEnabled = provider.IsEnabled,
|
||||
Authority = provider.Authority,
|
||||
ClientId = provider.ClientId,
|
||||
ClientSecret = MaskSecret(provider.ClientSecret),
|
||||
Scopes = provider.Scopes,
|
||||
ResponseType = provider.ResponseType,
|
||||
RequireHttpsMetadata = provider.RequireHttpsMetadata,
|
||||
ClaimMappings = provider.ClaimMappings,
|
||||
DisplayOrder = provider.DisplayOrder,
|
||||
TenantId = provider.TenantId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create or update provider
|
||||
/// </summary>
|
||||
public virtual async Task<ExternalAuthProviderDto> CreateOrUpdateProvider(CreateOrUpdateProviderInput input)
|
||||
{
|
||||
ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider provider;
|
||||
|
||||
if (input.Id.HasValue && input.Id.Value > 0)
|
||||
{
|
||||
// Update existing
|
||||
provider = await _providerRepository.GetAsync(input.Id.Value);
|
||||
provider.Name = input.Name;
|
||||
provider.ProviderType = input.ProviderType;
|
||||
provider.IsEnabled = input.IsEnabled;
|
||||
provider.Authority = input.Authority;
|
||||
provider.ClientId = input.ClientId;
|
||||
|
||||
// Only update secret if not masked
|
||||
if (!IsMaskedSecret(input.ClientSecret))
|
||||
{
|
||||
provider.ClientSecret = input.ClientSecret;
|
||||
}
|
||||
|
||||
provider.Scopes = input.Scopes;
|
||||
provider.ResponseType = input.ResponseType;
|
||||
provider.RequireHttpsMetadata = input.RequireHttpsMetadata;
|
||||
provider.ClaimMappings = input.ClaimMappings;
|
||||
provider.DisplayOrder = input.DisplayOrder;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new
|
||||
provider = new ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider
|
||||
{
|
||||
TenantId = AbpSession.TenantId,
|
||||
Name = input.Name,
|
||||
ProviderType = input.ProviderType,
|
||||
IsEnabled = input.IsEnabled,
|
||||
Authority = input.Authority,
|
||||
ClientId = input.ClientId,
|
||||
ClientSecret = input.ClientSecret,
|
||||
Scopes = input.Scopes,
|
||||
ResponseType = input.ResponseType,
|
||||
RequireHttpsMetadata = input.RequireHttpsMetadata,
|
||||
ClaimMappings = input.ClaimMappings,
|
||||
DisplayOrder = input.DisplayOrder
|
||||
};
|
||||
}
|
||||
|
||||
provider = await _providerManager.CreateOrUpdateAsync(provider);
|
||||
|
||||
return await GetProvider(provider.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete provider
|
||||
/// </summary>
|
||||
public virtual async Task DeleteProvider(int id)
|
||||
{
|
||||
await _providerManager.DeleteAsync(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test provider connection (OIDC discovery)
|
||||
/// </summary>
|
||||
public virtual async Task<TestConnectionOutput> TestProviderConnection(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await _providerManager.TestConnectionAsync(id);
|
||||
return new TestConnectionOutput
|
||||
{
|
||||
Success = success,
|
||||
Message = success ? "Connection successful" : "Connection failed"
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new TestConnectionOutput
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private string MaskSecret(string secret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (secret.Length <= 8)
|
||||
{
|
||||
return "********";
|
||||
}
|
||||
|
||||
return secret.Substring(0, 4) + "********" + secret.Substring(secret.Length - 4);
|
||||
}
|
||||
|
||||
private bool IsMaskedSecret(string secret)
|
||||
{
|
||||
return !string.IsNullOrEmpty(secret) && secret.Contains("********");
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
<PackageReference Include="Abp.AutoMapper" Version="10.2.0" />
|
||||
<PackageReference Include="Abp.ZeroCore.EntityFrameworkCore" Version="10.2.0" />
|
||||
<PackageReference Include="Castle.Windsor.MsDependencyInjection" Version="4.1.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.2" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.2.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using Abp.Domain.Entities;
|
||||
using Abp.Domain.Entities.Auditing;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ASPBaseOIDC.Authorization.ExternalAuth;
|
||||
|
||||
/// <summary>
|
||||
/// External authentication provider configuration (OIDC/OAuth2)
|
||||
/// Similar to Portainer's external auth system
|
||||
/// </summary>
|
||||
public class ExternalAuthProvider : FullAuditedEntity<int>, IMayHaveTenant
|
||||
{
|
||||
public const int MaxNameLength = 128;
|
||||
public const int MaxAuthorityLength = 512;
|
||||
public const int MaxClientIdLength = 256;
|
||||
public const int MaxClientSecretLength = 512;
|
||||
public const int MaxScopesLength = 512;
|
||||
public const int MaxResponseTypeLength = 64;
|
||||
public const int MaxClaimMappingsLength = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID (null for host)
|
||||
/// </summary>
|
||||
public int? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider display name (e.g., "Authentik", "Keycloak", "Google")
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(MaxNameLength)]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider type: "OIDC", "OAuth2", "SAML"
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(64)]
|
||||
public string ProviderType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enable/disable this provider
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OIDC Authority URL (e.g., https://auth.acme.com/application/o/app1/)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(MaxAuthorityLength)]
|
||||
public string Authority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2/OIDC Client ID
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(MaxClientIdLength)]
|
||||
public string ClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2/OIDC Client Secret (encrypted with SimpleStringCipher)
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(MaxClientSecretLength)]
|
||||
public string ClientSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Space-separated scopes (e.g., "openid profile email")
|
||||
/// </summary>
|
||||
[MaxLength(MaxScopesLength)]
|
||||
public string Scopes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth2 response type (e.g., "code", "id_token", "code id_token")
|
||||
/// </summary>
|
||||
[MaxLength(MaxResponseTypeLength)]
|
||||
public string ResponseType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Require HTTPS metadata endpoint
|
||||
/// </summary>
|
||||
public bool RequireHttpsMetadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON mapping for claims (e.g., {"sub": "sub", "email": "email", "name": "preferred_username"})
|
||||
/// </summary>
|
||||
[MaxLength(MaxClaimMappingsLength)]
|
||||
public string ClaimMappings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Display order in login page (lower = higher priority)
|
||||
/// </summary>
|
||||
public int DisplayOrder { get; set; }
|
||||
|
||||
public ExternalAuthProvider()
|
||||
{
|
||||
IsEnabled = true;
|
||||
RequireHttpsMetadata = true;
|
||||
ResponseType = "code";
|
||||
Scopes = "openid profile email";
|
||||
DisplayOrder = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using Abp;
|
||||
using Abp.Configuration;
|
||||
using Abp.Domain.Repositories;
|
||||
using Abp.Domain.Services;
|
||||
using Abp.Runtime.Caching;
|
||||
using Abp.Runtime.Security;
|
||||
using Abp.UI;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ASPBaseOIDC.Authorization.ExternalAuth;
|
||||
|
||||
/// <summary>
|
||||
/// Domain service for managing external authentication providers
|
||||
/// Handles CRUD, encryption, validation, and caching
|
||||
/// </summary>
|
||||
public class ExternalAuthProviderManager : DomainService
|
||||
{
|
||||
private readonly IRepository<ExternalAuthProvider> _providerRepository;
|
||||
private readonly ICacheManager _cacheManager;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private const string CacheKey = "ExternalAuthProviders";
|
||||
|
||||
public ExternalAuthProviderManager(
|
||||
IRepository<ExternalAuthProvider> providerRepository,
|
||||
ICacheManager cacheManager,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_providerRepository = providerRepository;
|
||||
_cacheManager = cacheManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all providers (with secrets decrypted for admin)
|
||||
/// </summary>
|
||||
public virtual async Task<List<ExternalAuthProvider>> GetAllAsync()
|
||||
{
|
||||
var providers = await _providerRepository.GetAllListAsync();
|
||||
return providers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all enabled providers (cached for performance)
|
||||
/// </summary>
|
||||
public virtual async Task<List<ExternalAuthProvider>> GetEnabledProvidersAsync()
|
||||
{
|
||||
var cache = _cacheManager.GetCache(CacheKey);
|
||||
var cacheItem = await cache.GetAsync("EnabledProviders", async (key) =>
|
||||
{
|
||||
var providers = await _providerRepository
|
||||
.GetAll()
|
||||
.Where(p => p.IsEnabled)
|
||||
.OrderBy(p => p.DisplayOrder)
|
||||
.ToListAsync();
|
||||
|
||||
return (object)providers;
|
||||
});
|
||||
|
||||
return cacheItem as List<ExternalAuthProvider> ?? new List<ExternalAuthProvider>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get provider by ID
|
||||
/// </summary>
|
||||
public virtual async Task<ExternalAuthProvider> GetByIdAsync(int id)
|
||||
{
|
||||
var provider = await _providerRepository.FirstOrDefaultAsync(id);
|
||||
if (provider == null)
|
||||
{
|
||||
throw new UserFriendlyException("Provider not found");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get provider by name
|
||||
/// </summary>
|
||||
public virtual async Task<ExternalAuthProvider> GetByNameAsync(string name)
|
||||
{
|
||||
return await _providerRepository.FirstOrDefaultAsync(p => p.Name == name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get provider by issuer (for token validation)
|
||||
/// </summary>
|
||||
public virtual async Task<ExternalAuthProvider> GetByIssuerAsync(string issuer)
|
||||
{
|
||||
var cache = _cacheManager.GetCache(CacheKey);
|
||||
var providers = await GetEnabledProvidersAsync();
|
||||
|
||||
return providers.FirstOrDefault(p =>
|
||||
issuer.StartsWith(p.Authority, StringComparison.OrdinalIgnoreCase) ||
|
||||
p.Authority.StartsWith(issuer, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create or update provider with validation and encryption
|
||||
/// </summary>
|
||||
public virtual async Task<ExternalAuthProvider> CreateOrUpdateAsync(ExternalAuthProvider provider)
|
||||
{
|
||||
ValidateProvider(provider);
|
||||
|
||||
// Encrypt client secret using ABP's SimpleStringCipher
|
||||
if (!string.IsNullOrEmpty(provider.ClientSecret))
|
||||
{
|
||||
provider.ClientSecret = SimpleStringCipher.Instance.Encrypt(provider.ClientSecret);
|
||||
}
|
||||
|
||||
if (provider.Id == 0)
|
||||
{
|
||||
await _providerRepository.InsertAsync(provider);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _providerRepository.UpdateAsync(provider);
|
||||
}
|
||||
|
||||
await CurrentUnitOfWork.SaveChangesAsync();
|
||||
ClearCache();
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete provider (soft delete)
|
||||
/// </summary>
|
||||
public virtual async Task DeleteAsync(int id)
|
||||
{
|
||||
await _providerRepository.DeleteAsync(id);
|
||||
ClearCache();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test provider connectivity (OIDC discovery endpoint)
|
||||
/// </summary>
|
||||
public virtual async Task<bool> TestConnectionAsync(int id)
|
||||
{
|
||||
var provider = await GetByIdAsync(id);
|
||||
return await TestConnectionAsync(provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test provider connectivity
|
||||
/// </summary>
|
||||
public virtual async Task<bool> TestConnectionAsync(ExternalAuthProvider provider)
|
||||
{
|
||||
try
|
||||
{
|
||||
var discoveryUrl = provider.Authority.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
var response = await httpClient.GetAsync(discoveryUrl);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var doc = JsonDocument.Parse(content);
|
||||
|
||||
// Validate required OIDC fields
|
||||
return doc.RootElement.TryGetProperty("issuer", out _) &&
|
||||
doc.RootElement.TryGetProperty("authorization_endpoint", out _) &&
|
||||
doc.RootElement.TryGetProperty("token_endpoint", out _);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypt client secret for use (after retrieving from DB)
|
||||
/// </summary>
|
||||
public virtual string DecryptClientSecret(string encryptedSecret)
|
||||
{
|
||||
if (string.IsNullOrEmpty(encryptedSecret))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return SimpleStringCipher.Instance.Decrypt(encryptedSecret);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If decryption fails, assume it's already plain text (for migration scenarios)
|
||||
return encryptedSecret;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get claim mappings as dictionary
|
||||
/// </summary>
|
||||
public virtual Dictionary<string, string> GetClaimMappings(ExternalAuthProvider provider)
|
||||
{
|
||||
if (string.IsNullOrEmpty(provider.ClaimMappings))
|
||||
{
|
||||
// Default mappings
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
{ "sub", "sub" },
|
||||
{ "email", "email" },
|
||||
{ "name", "name" },
|
||||
{ "preferred_username", "preferred_username" }
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(provider.ClaimMappings);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateProvider(ExternalAuthProvider provider)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(provider.Name))
|
||||
{
|
||||
throw new UserFriendlyException("Provider name is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(provider.Authority))
|
||||
{
|
||||
throw new UserFriendlyException("Authority URL is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(provider.ClientId))
|
||||
{
|
||||
throw new UserFriendlyException("Client ID is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(provider.ClientSecret))
|
||||
{
|
||||
throw new UserFriendlyException("Client Secret is required");
|
||||
}
|
||||
|
||||
// Validate Authority is a valid URL
|
||||
if (!Uri.TryCreate(provider.Authority, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new UserFriendlyException("Authority must be a valid URL");
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearCache()
|
||||
{
|
||||
var cache = _cacheManager.GetCache(CacheKey);
|
||||
cache.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
using Abp;
|
||||
using Abp.Authorization;
|
||||
using Abp.Authorization.Users;
|
||||
using Abp.Configuration;
|
||||
using Abp.Domain.Repositories;
|
||||
using Abp.Domain.Services;
|
||||
using Abp.Domain.Uow;
|
||||
using Abp.UI;
|
||||
using ASPBaseOIDC.Authorization.Roles;
|
||||
using ASPBaseOIDC.Authorization.Users;
|
||||
using ASPBaseOIDC.Configuration;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ASPBaseOIDC.Authorization.ExternalAuth;
|
||||
|
||||
/// <summary>
|
||||
/// Domain service for external authentication with OIDC/OAuth2 providers
|
||||
/// Handles token validation, JIT provisioning, and user linking
|
||||
/// </summary>
|
||||
public class ExternalAuthenticationManager : DomainService
|
||||
{
|
||||
private readonly ExternalAuthProviderManager _providerManager;
|
||||
private readonly UserManager _userManager;
|
||||
private readonly RoleManager _roleManager;
|
||||
private readonly IRepository<UserLogin, long> _userLoginRepository;
|
||||
private readonly IRepository<UserLoginAttempt, long> _userLoginAttemptRepository;
|
||||
private readonly ISettingManager _settingManager;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IUnitOfWorkManager _unitOfWorkManager;
|
||||
|
||||
public ExternalAuthenticationManager(
|
||||
ExternalAuthProviderManager providerManager,
|
||||
UserManager userManager,
|
||||
RoleManager roleManager,
|
||||
IRepository<UserLogin, long> userLoginRepository,
|
||||
IRepository<UserLoginAttempt, long> userLoginAttemptRepository,
|
||||
ISettingManager settingManager,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IUnitOfWorkManager unitOfWorkManager)
|
||||
{
|
||||
_providerManager = providerManager;
|
||||
_userManager = userManager;
|
||||
_roleManager = roleManager;
|
||||
_userLoginRepository = userLoginRepository;
|
||||
_userLoginAttemptRepository = userLoginAttemptRepository;
|
||||
_settingManager = settingManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_unitOfWorkManager = unitOfWorkManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticate user with external OIDC token
|
||||
/// Main flow: validate token → find/create user → link provider → return result
|
||||
/// </summary>
|
||||
public virtual async Task<ExternalAuthenticationResult> AuthenticateWithExternalTokenAsync(
|
||||
string providerName,
|
||||
string idToken,
|
||||
int? tenantId = null)
|
||||
{
|
||||
// 1. Check if external auth is enabled
|
||||
var isEnabled = await _settingManager.GetSettingValueAsync<bool>(AppSettingNames.ExternalAuth.Enabled);
|
||||
if (!isEnabled)
|
||||
{
|
||||
throw new UserFriendlyException("External authentication is disabled");
|
||||
}
|
||||
|
||||
// 2. Get provider configuration
|
||||
var provider = await _providerManager.GetByNameAsync(providerName);
|
||||
if (provider == null || !provider.IsEnabled)
|
||||
{
|
||||
throw new UserFriendlyException("Provider not found or disabled");
|
||||
}
|
||||
|
||||
// 3. Validate JWT token
|
||||
var claims = await ValidateTokenAsync(provider, idToken);
|
||||
var sub = claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||
var email = claims.FirstOrDefault(c => c.Type == "email")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(sub))
|
||||
{
|
||||
throw new UserFriendlyException("Invalid token: 'sub' claim not found");
|
||||
}
|
||||
|
||||
// 4. Find or create user
|
||||
User user;
|
||||
using (CurrentUnitOfWork.SetTenantId(tenantId))
|
||||
{
|
||||
user = await FindOrCreateUserAsync(provider, sub, email, claims, tenantId);
|
||||
|
||||
// 5. Register login attempt
|
||||
await CreateLoginAttemptAsync(user, provider.Name, true, tenantId);
|
||||
}
|
||||
|
||||
// 6. Return result with original token (passthrough)
|
||||
return new ExternalAuthenticationResult
|
||||
{
|
||||
User = user,
|
||||
AccessToken = idToken,
|
||||
Provider = provider.Name,
|
||||
ExpiresIn = GetTokenExpiration(idToken),
|
||||
Claims = claims
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate JWT token with provider's JWKS
|
||||
/// </summary>
|
||||
private async Task<List<Claim>> ValidateTokenAsync(ExternalAuthProvider provider, string idToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
// First, read token without validation to get issuer
|
||||
var jwtToken = handler.ReadJwtToken(idToken);
|
||||
|
||||
// Download OIDC configuration
|
||||
var discoveryUrl = provider.Authority.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
var discoveryResponse = await httpClient.GetStringAsync(discoveryUrl);
|
||||
var discoveryDoc = JsonDocument.Parse(discoveryResponse);
|
||||
|
||||
var jwksUri = discoveryDoc.RootElement.GetProperty("jwks_uri").GetString();
|
||||
var issuer = discoveryDoc.RootElement.GetProperty("issuer").GetString();
|
||||
|
||||
// Download JWKS
|
||||
var jwksResponse = await httpClient.GetStringAsync(jwksUri);
|
||||
|
||||
// Create validation parameters
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = provider.ClientId,
|
||||
ValidateLifetime = true,
|
||||
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())
|
||||
{
|
||||
var keyId = key.GetProperty("kid").GetString();
|
||||
if (keyId == kid)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return signingKeys;
|
||||
},
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
// Validate token
|
||||
var principal = handler.ValidateToken(idToken, validationParameters, out var validatedToken);
|
||||
return principal.Claims.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Token validation failed", ex);
|
||||
throw new UserFriendlyException("Invalid token: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find existing user or create new one (JIT provisioning)
|
||||
/// </summary>
|
||||
private async Task<User> FindOrCreateUserAsync(
|
||||
ExternalAuthProvider provider,
|
||||
string sub,
|
||||
string email,
|
||||
List<Claim> claims,
|
||||
int? tenantId)
|
||||
{
|
||||
// 1. Try to find existing link by provider + sub
|
||||
var existingLogin = await _userLoginRepository.FirstOrDefaultAsync(ul =>
|
||||
ul.LoginProvider == provider.Name && ul.ProviderKey == sub && ul.TenantId == tenantId);
|
||||
|
||||
if (existingLogin != null)
|
||||
{
|
||||
var existingUser = await _userManager.FindByIdAsync(existingLogin.UserId.ToString());
|
||||
if (existingUser != null)
|
||||
{
|
||||
return existingUser;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if auto-provisioning is enabled
|
||||
var autoProvision = await _settingManager.GetSettingValueAsync<bool>(
|
||||
AppSettingNames.ExternalAuth.AutoProvisionUsers);
|
||||
|
||||
if (!autoProvision)
|
||||
{
|
||||
throw new UserFriendlyException("User not found and auto-provisioning is disabled");
|
||||
}
|
||||
|
||||
// 3. Try to find user by email (merge scenario)
|
||||
User user = null;
|
||||
if (!string.IsNullOrEmpty(email))
|
||||
{
|
||||
user = await _userManager.FindByEmailAsync(email);
|
||||
}
|
||||
|
||||
// 4. Create new user if not found
|
||||
if (user == null)
|
||||
{
|
||||
user = await CreateNewUserAsync(provider, claims, tenantId);
|
||||
}
|
||||
|
||||
// 5. Link external provider to user
|
||||
await LinkProviderToUserAsync(user, provider.Name, sub);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create new user from external claims
|
||||
/// </summary>
|
||||
private async Task<User> CreateNewUserAsync(
|
||||
ExternalAuthProvider provider,
|
||||
List<Claim> claims,
|
||||
int? tenantId)
|
||||
{
|
||||
var claimMappings = _providerManager.GetClaimMappings(provider);
|
||||
|
||||
var email = GetClaimValue(claims, claimMappings, "email");
|
||||
var name = GetClaimValue(claims, claimMappings, "name") ?? "Unknown";
|
||||
var preferredUsername = GetClaimValue(claims, claimMappings, "preferred_username") ?? email?.Split('@')[0];
|
||||
|
||||
if (string.IsNullOrEmpty(email))
|
||||
{
|
||||
throw new UserFriendlyException("Email claim is required for user provisioning");
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserName = preferredUsername ?? Guid.NewGuid().ToString("N").Substring(0, 10),
|
||||
Name = name,
|
||||
Surname = GetClaimValue(claims, claimMappings, "family_name") ?? name,
|
||||
EmailAddress = email,
|
||||
IsEmailConfirmed = true, // Trust external provider
|
||||
IsActive = true,
|
||||
Password = Guid.NewGuid().ToString("N") // Random password (user won't use it)
|
||||
};
|
||||
|
||||
user.SetNormalizedNames();
|
||||
|
||||
var result = await _userManager.CreateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
throw new UserFriendlyException("Failed to create user: " + string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
// Assign default role if configured
|
||||
var defaultRole = await _settingManager.GetSettingValueAsync(AppSettingNames.ExternalAuth.DefaultRole);
|
||||
if (!string.IsNullOrEmpty(defaultRole))
|
||||
{
|
||||
var role = await _roleManager.FindByNameAsync(defaultRole);
|
||||
if (role != null)
|
||||
{
|
||||
await _userManager.AddToRoleAsync(user, defaultRole);
|
||||
}
|
||||
}
|
||||
|
||||
await CurrentUnitOfWork.SaveChangesAsync();
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Link external provider to user account
|
||||
/// </summary>
|
||||
private async Task LinkProviderToUserAsync(User user, string providerName, string providerKey)
|
||||
{
|
||||
var existingLink = await _userLoginRepository.FirstOrDefaultAsync(ul =>
|
||||
ul.UserId == user.Id && ul.LoginProvider == providerName);
|
||||
|
||||
if (existingLink == null)
|
||||
{
|
||||
await _userLoginRepository.InsertAsync(new UserLogin
|
||||
{
|
||||
TenantId = user.TenantId,
|
||||
UserId = user.Id,
|
||||
LoginProvider = providerName,
|
||||
ProviderKey = providerKey
|
||||
});
|
||||
|
||||
await CurrentUnitOfWork.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create login attempt record
|
||||
/// </summary>
|
||||
private async Task CreateLoginAttemptAsync(User user, string providerName, bool success, int? tenantId)
|
||||
{
|
||||
await _userLoginAttemptRepository.InsertAsync(new UserLoginAttempt
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = user.Id,
|
||||
UserNameOrEmailAddress = user.EmailAddress,
|
||||
ClientIpAddress = "External",
|
||||
ClientName = providerName,
|
||||
BrowserInfo = "OIDC",
|
||||
Result = success ? AbpLoginResultType.Success : AbpLoginResultType.InvalidPassword,
|
||||
CreationTime = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await CurrentUnitOfWork.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private string GetClaimValue(List<Claim> claims, Dictionary<string, string> mappings, string claimType)
|
||||
{
|
||||
if (!mappings.TryGetValue(claimType, out var mappedType))
|
||||
{
|
||||
mappedType = claimType;
|
||||
}
|
||||
|
||||
return claims.FirstOrDefault(c => c.Type == mappedType)?.Value;
|
||||
}
|
||||
|
||||
private int GetTokenExpiration(string idToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var token = handler.ReadJwtToken(idToken);
|
||||
var exp = token.ValidTo;
|
||||
return (int)(exp - DateTime.UtcNow).TotalSeconds;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 3600; // Default 1 hour
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of external authentication
|
||||
/// </summary>
|
||||
public class ExternalAuthenticationResult
|
||||
{
|
||||
public User User { get; set; }
|
||||
public string AccessToken { get; set; }
|
||||
public string Provider { get; set; }
|
||||
public int ExpiresIn { get; set; }
|
||||
public List<Claim> Claims { get; set; }
|
||||
}
|
||||
@@ -3,6 +3,32 @@
|
||||
public static class AppSettingNames
|
||||
{
|
||||
public const string UiTheme = "App.UiTheme";
|
||||
|
||||
/// <summary>
|
||||
/// External Authentication Settings
|
||||
/// </summary>
|
||||
public static class ExternalAuth
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable/disable external authentication module globally
|
||||
/// </summary>
|
||||
public const string Enabled = "App.ExternalAuth.Enabled";
|
||||
|
||||
/// <summary>
|
||||
/// Allow local login (username/password) alongside external providers
|
||||
/// </summary>
|
||||
public const string AllowLocalLogin = "App.ExternalAuth.AllowLocalLogin";
|
||||
|
||||
/// <summary>
|
||||
/// Automatically provision users on first external login (JIT provisioning)
|
||||
/// </summary>
|
||||
public const string AutoProvisionUsers = "App.ExternalAuth.AutoProvisionUsers";
|
||||
|
||||
/// <summary>
|
||||
/// Default role name for auto-provisioned users (empty = no role assigned)
|
||||
/// </summary>
|
||||
public const string DefaultRole = "App.ExternalAuth.DefaultRole";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,32 @@ public class AppSettingProvider : SettingProvider
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new SettingDefinition(AppSettingNames.UiTheme, "red", scopes: SettingScopes.Application | SettingScopes.Tenant | SettingScopes.User, clientVisibilityProvider: new VisibleSettingClientVisibilityProvider())
|
||||
new SettingDefinition(AppSettingNames.UiTheme, "red", scopes: SettingScopes.Application | SettingScopes.Tenant | SettingScopes.User, clientVisibilityProvider: new VisibleSettingClientVisibilityProvider()),
|
||||
|
||||
// External Authentication Settings
|
||||
new SettingDefinition(
|
||||
AppSettingNames.ExternalAuth.Enabled,
|
||||
"false",
|
||||
scopes: SettingScopes.Application | SettingScopes.Tenant
|
||||
),
|
||||
|
||||
new SettingDefinition(
|
||||
AppSettingNames.ExternalAuth.AllowLocalLogin,
|
||||
"true",
|
||||
scopes: SettingScopes.Application | SettingScopes.Tenant
|
||||
),
|
||||
|
||||
new SettingDefinition(
|
||||
AppSettingNames.ExternalAuth.AutoProvisionUsers,
|
||||
"true",
|
||||
scopes: SettingScopes.Application | SettingScopes.Tenant
|
||||
),
|
||||
|
||||
new SettingDefinition(
|
||||
AppSettingNames.ExternalAuth.DefaultRole,
|
||||
"", // Empty = no default role
|
||||
scopes: SettingScopes.Application | SettingScopes.Tenant
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Abp.Zero.EntityFrameworkCore;
|
||||
using ASPBaseOIDC.Authorization.ExternalAuth;
|
||||
using ASPBaseOIDC.Authorization.Roles;
|
||||
using ASPBaseOIDC.Authorization.Users;
|
||||
using ASPBaseOIDC.MultiTenancy;
|
||||
@@ -12,6 +13,8 @@ public class ASPBaseOIDCDbContext : AbpZeroDbContext<Tenant, Role, User, ASPBase
|
||||
{
|
||||
/* Define a DbSet for each entity of the application */
|
||||
|
||||
public DbSet<ExternalAuthProvider> ExternalAuthProviders { get; set; }
|
||||
|
||||
public ASPBaseOIDCDbContext(DbContextOptions<ASPBaseOIDCDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using Abp;
|
||||
using Abp.Runtime.Security;
|
||||
using ASPBaseOIDC.Authorization.ExternalAuth;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ASPBaseOIDC.EntityFrameworkCore.Seed.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds default external authentication providers for development/demo purposes
|
||||
/// </summary>
|
||||
public class DefaultExternalAuthProvidersCreator
|
||||
{
|
||||
private readonly ASPBaseOIDCDbContext _context;
|
||||
|
||||
public DefaultExternalAuthProvidersCreator(ASPBaseOIDCDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public void Create()
|
||||
{
|
||||
CreateAuthentikProvider();
|
||||
CreateKeycloakProvider();
|
||||
}
|
||||
|
||||
private void CreateAuthentikProvider()
|
||||
{
|
||||
var authentik = _context.ExternalAuthProviders.FirstOrDefault(p => p.Name == "Authentik");
|
||||
if (authentik == null)
|
||||
{
|
||||
var claimMappings = new
|
||||
{
|
||||
sub = "sub",
|
||||
email = "email",
|
||||
name = "name",
|
||||
preferred_username = "preferred_username",
|
||||
groups = "groups"
|
||||
};
|
||||
|
||||
_context.ExternalAuthProviders.Add(new ExternalAuthProvider
|
||||
{
|
||||
TenantId = null, // Host provider
|
||||
Name = "Authentik",
|
||||
ProviderType = "OIDC",
|
||||
IsEnabled = false, // Disabled by default - admin must configure and enable
|
||||
Authority = "http://localhost:9000/application/o/aspbaseoic/",
|
||||
ClientId = "aspbase-client",
|
||||
ClientSecret = SimpleStringCipher.Instance.Encrypt("CHANGE_THIS_SECRET"), // Encrypted
|
||||
Scopes = "openid profile email",
|
||||
ResponseType = "code",
|
||||
RequireHttpsMetadata = false, // For local development only
|
||||
ClaimMappings = JsonSerializer.Serialize(claimMappings),
|
||||
DisplayOrder = 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateKeycloakProvider()
|
||||
{
|
||||
var keycloak = _context.ExternalAuthProviders.FirstOrDefault(p => p.Name == "Keycloak");
|
||||
if (keycloak == null)
|
||||
{
|
||||
var claimMappings = new
|
||||
{
|
||||
sub = "sub",
|
||||
email = "email",
|
||||
name = "name",
|
||||
preferred_username = "preferred_username",
|
||||
groups = "groups"
|
||||
};
|
||||
|
||||
_context.ExternalAuthProviders.Add(new ExternalAuthProvider
|
||||
{
|
||||
TenantId = null,
|
||||
Name = "Keycloak",
|
||||
ProviderType = "OIDC",
|
||||
IsEnabled = false,
|
||||
Authority = "http://localhost:8080/realms/master/",
|
||||
ClientId = "aspbase-client",
|
||||
ClientSecret = SimpleStringCipher.Instance.Encrypt("CHANGE_THIS_SECRET"),
|
||||
Scopes = "openid profile email",
|
||||
ResponseType = "code",
|
||||
RequireHttpsMetadata = false,
|
||||
ClaimMappings = JsonSerializer.Serialize(claimMappings),
|
||||
DisplayOrder = 2
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public class InitialHostDbBuilder
|
||||
new DefaultLanguagesCreator(_context).Create();
|
||||
new HostRoleAndUserCreator(_context).Create();
|
||||
new DefaultSettingsCreator(_context).Create();
|
||||
new DefaultExternalAuthProvidersCreator(_context).Create();
|
||||
|
||||
_context.SaveChanges();
|
||||
}
|
||||
|
||||
2051
src/ASPBaseOIDC.EntityFrameworkCore/Migrations/20251007010025_AddExternalAuthProviders.Designer.cs
generated
Normal file
2051
src/ASPBaseOIDC.EntityFrameworkCore/Migrations/20251007010025_AddExternalAuthProviders.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ASPBaseOIDC.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddExternalAuthProviders : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalAuthProviders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TenantId = table.Column<int>(type: "integer", nullable: true),
|
||||
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
ProviderType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Authority = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
ClientId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
ClientSecret = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||
Scopes = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
ResponseType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
RequireHttpsMetadata = table.Column<bool>(type: "boolean", nullable: false),
|
||||
ClaimMappings = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
DisplayOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
CreationTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatorUserId = table.Column<long>(type: "bigint", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
LastModifierUserId = table.Column<long>(type: "bigint", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DeleterUserId = table.Column<long>(type: "bigint", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalAuthProviders", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalAuthProviders");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,89 @@ namespace ASPBaseOIDC.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Authority")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("ClaimMappings")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("ClientId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("ClientSecret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<DateTime>("CreationTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("CreatorUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("DeleterUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("DisplayOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("LastModifierUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("ProviderType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("RequireHttpsMetadata")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ResponseType")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Scopes")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ExternalAuthProviders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ASPBaseOIDC.Authorization.Roles.Role", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using ASPBaseOIDC.Authorization.ExternalAuth;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ASPBaseOIDC.Authentication.ExternalAuth
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic OIDC authentication handler that validates tokens from multiple external providers
|
||||
/// Automatically detects provider by token issuer and validates accordingly
|
||||
/// </summary>
|
||||
public class DynamicOidcHandler : AuthenticationHandler<JwtBearerOptions>
|
||||
{
|
||||
private readonly ExternalAuthProviderManager _providerManager;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public DynamicOidcHandler(
|
||||
IOptionsMonitor<JwtBearerOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock,
|
||||
ExternalAuthProviderManager providerManager,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
: base(options, logger, encoder, clock)
|
||||
{
|
||||
_providerManager = providerManager;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Extract token from Authorization header
|
||||
var token = ExtractToken();
|
||||
if (token == null)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
// 2. Read token without validation to get issuer
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
if (!handler.CanReadToken(token))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
var jwtToken = handler.ReadJwtToken(token);
|
||||
var issuer = jwtToken.Issuer;
|
||||
|
||||
// 3. Find provider by issuer (cached)
|
||||
var provider = await _providerManager.GetByIssuerAsync(issuer);
|
||||
if (provider == null || !provider.IsEnabled)
|
||||
{
|
||||
Logger.LogWarning($"No enabled provider found for issuer: {issuer}");
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
// 4. Validate token with provider configuration
|
||||
var validationParams = await BuildValidationParametersAsync(provider);
|
||||
var principal = handler.ValidateToken(token, validationParams, out var validatedToken);
|
||||
|
||||
// 5. Create authentication ticket
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Token validation failed");
|
||||
return AuthenticateResult.Fail(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Unexpected error during authentication");
|
||||
return AuthenticateResult.Fail("Authentication failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract bearer token from Authorization header
|
||||
/// </summary>
|
||||
private string ExtractToken()
|
||||
{
|
||||
var authorization = Request.Headers["Authorization"].ToString();
|
||||
if (string.IsNullOrEmpty(authorization))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return authorization.Substring("Bearer ".Length).Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build token validation parameters for specific provider
|
||||
/// </summary>
|
||||
private async Task<TokenValidationParameters> BuildValidationParametersAsync(
|
||||
ASPBaseOIDC.Authorization.ExternalAuth.ExternalAuthProvider provider)
|
||||
{
|
||||
// Download OIDC discovery document
|
||||
var discoveryUrl = provider.Authority.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
|
||||
var discoveryResponse = await httpClient.GetStringAsync(discoveryUrl);
|
||||
var discoveryDoc = JsonDocument.Parse(discoveryResponse);
|
||||
|
||||
var issuer = discoveryDoc.RootElement.GetProperty("issuer").GetString();
|
||||
var jwksUri = discoveryDoc.RootElement.GetProperty("jwks_uri").GetString();
|
||||
|
||||
// Download JWKS
|
||||
var jwksResponse = await httpClient.GetStringAsync(jwksUri);
|
||||
var jwks = JsonDocument.Parse(jwksResponse);
|
||||
|
||||
return new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = provider.ClientId,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
|
||||
{
|
||||
return ResolveSigningKeys(jwks, kid);
|
||||
},
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve signing keys from JWKS
|
||||
/// </summary>
|
||||
private IEnumerable<SecurityKey> ResolveSigningKeys(JsonDocument jwks, string kid)
|
||||
{
|
||||
var keys = jwks.RootElement.GetProperty("keys");
|
||||
var signingKeys = new List<SecurityKey>();
|
||||
|
||||
foreach (var key in keys.EnumerateArray())
|
||||
{
|
||||
var keyId = key.GetProperty("kid").GetString();
|
||||
if (keyId == kid)
|
||||
{
|
||||
var kty = key.GetProperty("kty").GetString();
|
||||
|
||||
if (kty == "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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return signingKeys;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
using Abp.Authorization.Users;
|
||||
using Abp.MultiTenancy;
|
||||
using Abp.Runtime.Security;
|
||||
using ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
|
||||
using ASPBaseOIDC.Authentication.JwtBearer;
|
||||
using ASPBaseOIDC.Authorization;
|
||||
using ASPBaseOIDC.Authorization.ExternalAuth;
|
||||
using ASPBaseOIDC.Authorization.Users;
|
||||
using ASPBaseOIDC.Models.TokenAuth;
|
||||
using ASPBaseOIDC.MultiTenancy;
|
||||
@@ -24,17 +26,20 @@ namespace ASPBaseOIDC.Controllers
|
||||
private readonly ITenantCache _tenantCache;
|
||||
private readonly AbpLoginResultTypeHelper _abpLoginResultTypeHelper;
|
||||
private readonly TokenAuthConfiguration _configuration;
|
||||
private readonly ExternalAuthenticationManager _externalAuthManager;
|
||||
|
||||
public TokenAuthController(
|
||||
LogInManager logInManager,
|
||||
ITenantCache tenantCache,
|
||||
AbpLoginResultTypeHelper abpLoginResultTypeHelper,
|
||||
TokenAuthConfiguration configuration)
|
||||
TokenAuthConfiguration configuration,
|
||||
ExternalAuthenticationManager externalAuthManager)
|
||||
{
|
||||
_logInManager = logInManager;
|
||||
_tenantCache = tenantCache;
|
||||
_abpLoginResultTypeHelper = abpLoginResultTypeHelper;
|
||||
_configuration = configuration;
|
||||
_externalAuthManager = externalAuthManager;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@@ -57,6 +62,31 @@ namespace ASPBaseOIDC.Controllers
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticate with external OIDC/OAuth2 provider (Authentik, Keycloak, etc.)
|
||||
/// Passthrough approach: validates external token and returns it as-is
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[AbpAllowAnonymous]
|
||||
public async Task<AuthenticateResultModel> AuthenticateExternal([FromBody] ExternalAuthModel model)
|
||||
{
|
||||
// Authenticate with external provider (validates token, provisions user if needed)
|
||||
var result = await _externalAuthManager.AuthenticateWithExternalTokenAsync(
|
||||
model.ProviderName,
|
||||
model.IdToken,
|
||||
AbpSession.TenantId
|
||||
);
|
||||
|
||||
// Return original external token (passthrough approach)
|
||||
return new AuthenticateResultModel
|
||||
{
|
||||
AccessToken = result.AccessToken, // Passthrough external token
|
||||
EncryptedAccessToken = GetEncryptedAccessToken(result.AccessToken),
|
||||
ExpireInSeconds = result.ExpiresIn,
|
||||
UserId = result.User.Id
|
||||
};
|
||||
}
|
||||
|
||||
private string GetTenancyNameOrNull()
|
||||
{
|
||||
if (!AbpSession.TenantId.HasValue)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Abp.Runtime.Security;
|
||||
using ASPBaseOIDC.Authentication.ExternalAuth;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -20,7 +22,9 @@ namespace ASPBaseOIDC.Web.Host.Startup
|
||||
{
|
||||
options.DefaultAuthenticateScheme = "JwtBearer";
|
||||
options.DefaultChallengeScheme = "JwtBearer";
|
||||
}).AddJwtBearer("JwtBearer", options =>
|
||||
})
|
||||
// Local JWT Bearer authentication (username/password)
|
||||
.AddJwtBearer("JwtBearer", options =>
|
||||
{
|
||||
options.Audience = configuration["Authentication:JwtBearer:Audience"];
|
||||
|
||||
@@ -49,6 +53,19 @@ namespace ASPBaseOIDC.Web.Host.Startup
|
||||
{
|
||||
OnMessageReceived = QueryStringTokenResolver
|
||||
};
|
||||
})
|
||||
// Dynamic OIDC authentication for external providers (Authentik, Keycloak, etc.)
|
||||
.AddScheme<JwtBearerOptions, DynamicOidcHandler>("DynamicOidc", options =>
|
||||
{
|
||||
// Options are handled dynamically by DynamicOidcHandler
|
||||
});
|
||||
|
||||
// Configure authorization to accept both local and external tokens
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.DefaultPolicy = new AuthorizationPolicyBuilder("JwtBearer", "DynamicOidc")
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"Default": "User Id=postgres;Password=admin57$;Server=134.199.235.213;Port=5432;Database=isa_base_auth_db"
|
||||
"Default": "User Id=postgres;Password=admin57$;Server=134.199.235.213;Port=5432;Database=isa_base_auth"
|
||||
},
|
||||
"App": {
|
||||
"ServerRootAddress": "https://localhost:44311/",
|
||||
|
||||
Reference in New Issue
Block a user