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.AutoMapper" Version="10.2.0" />
|
||||||
<PackageReference Include="Abp.ZeroCore.EntityFrameworkCore" Version="10.2.0" />
|
<PackageReference Include="Abp.ZeroCore.EntityFrameworkCore" Version="10.2.0" />
|
||||||
<PackageReference Include="Castle.Windsor.MsDependencyInjection" Version="4.1.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>
|
</ItemGroup>
|
||||||
</Project>
|
</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 static class AppSettingNames
|
||||||
{
|
{
|
||||||
public const string UiTheme = "App.UiTheme";
|
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[]
|
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 Abp.Zero.EntityFrameworkCore;
|
||||||
|
using ASPBaseOIDC.Authorization.ExternalAuth;
|
||||||
using ASPBaseOIDC.Authorization.Roles;
|
using ASPBaseOIDC.Authorization.Roles;
|
||||||
using ASPBaseOIDC.Authorization.Users;
|
using ASPBaseOIDC.Authorization.Users;
|
||||||
using ASPBaseOIDC.MultiTenancy;
|
using ASPBaseOIDC.MultiTenancy;
|
||||||
@@ -12,6 +13,8 @@ public class ASPBaseOIDCDbContext : AbpZeroDbContext<Tenant, Role, User, ASPBase
|
|||||||
{
|
{
|
||||||
/* Define a DbSet for each entity of the application */
|
/* Define a DbSet for each entity of the application */
|
||||||
|
|
||||||
|
public DbSet<ExternalAuthProvider> ExternalAuthProviders { get; set; }
|
||||||
|
|
||||||
public ASPBaseOIDCDbContext(DbContextOptions<ASPBaseOIDCDbContext> options)
|
public ASPBaseOIDCDbContext(DbContextOptions<ASPBaseOIDCDbContext> options)
|
||||||
: base(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 DefaultLanguagesCreator(_context).Create();
|
||||||
new HostRoleAndUserCreator(_context).Create();
|
new HostRoleAndUserCreator(_context).Create();
|
||||||
new DefaultSettingsCreator(_context).Create();
|
new DefaultSettingsCreator(_context).Create();
|
||||||
|
new DefaultExternalAuthProvidersCreator(_context).Create();
|
||||||
|
|
||||||
_context.SaveChanges();
|
_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);
|
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 =>
|
modelBuilder.Entity("ASPBaseOIDC.Authorization.Roles.Role", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
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.Authorization.Users;
|
||||||
using Abp.MultiTenancy;
|
using Abp.MultiTenancy;
|
||||||
using Abp.Runtime.Security;
|
using Abp.Runtime.Security;
|
||||||
|
using ASPBaseOIDC.Application.Authorization.ExternalAuth.Dto;
|
||||||
using ASPBaseOIDC.Authentication.JwtBearer;
|
using ASPBaseOIDC.Authentication.JwtBearer;
|
||||||
using ASPBaseOIDC.Authorization;
|
using ASPBaseOIDC.Authorization;
|
||||||
|
using ASPBaseOIDC.Authorization.ExternalAuth;
|
||||||
using ASPBaseOIDC.Authorization.Users;
|
using ASPBaseOIDC.Authorization.Users;
|
||||||
using ASPBaseOIDC.Models.TokenAuth;
|
using ASPBaseOIDC.Models.TokenAuth;
|
||||||
using ASPBaseOIDC.MultiTenancy;
|
using ASPBaseOIDC.MultiTenancy;
|
||||||
@@ -24,17 +26,20 @@ namespace ASPBaseOIDC.Controllers
|
|||||||
private readonly ITenantCache _tenantCache;
|
private readonly ITenantCache _tenantCache;
|
||||||
private readonly AbpLoginResultTypeHelper _abpLoginResultTypeHelper;
|
private readonly AbpLoginResultTypeHelper _abpLoginResultTypeHelper;
|
||||||
private readonly TokenAuthConfiguration _configuration;
|
private readonly TokenAuthConfiguration _configuration;
|
||||||
|
private readonly ExternalAuthenticationManager _externalAuthManager;
|
||||||
|
|
||||||
public TokenAuthController(
|
public TokenAuthController(
|
||||||
LogInManager logInManager,
|
LogInManager logInManager,
|
||||||
ITenantCache tenantCache,
|
ITenantCache tenantCache,
|
||||||
AbpLoginResultTypeHelper abpLoginResultTypeHelper,
|
AbpLoginResultTypeHelper abpLoginResultTypeHelper,
|
||||||
TokenAuthConfiguration configuration)
|
TokenAuthConfiguration configuration,
|
||||||
|
ExternalAuthenticationManager externalAuthManager)
|
||||||
{
|
{
|
||||||
_logInManager = logInManager;
|
_logInManager = logInManager;
|
||||||
_tenantCache = tenantCache;
|
_tenantCache = tenantCache;
|
||||||
_abpLoginResultTypeHelper = abpLoginResultTypeHelper;
|
_abpLoginResultTypeHelper = abpLoginResultTypeHelper;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
|
_externalAuthManager = externalAuthManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[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()
|
private string GetTenancyNameOrNull()
|
||||||
{
|
{
|
||||||
if (!AbpSession.TenantId.HasValue)
|
if (!AbpSession.TenantId.HasValue)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Abp.Runtime.Security;
|
using Abp.Runtime.Security;
|
||||||
|
using ASPBaseOIDC.Authentication.ExternalAuth;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
@@ -20,7 +22,9 @@ namespace ASPBaseOIDC.Web.Host.Startup
|
|||||||
{
|
{
|
||||||
options.DefaultAuthenticateScheme = "JwtBearer";
|
options.DefaultAuthenticateScheme = "JwtBearer";
|
||||||
options.DefaultChallengeScheme = "JwtBearer";
|
options.DefaultChallengeScheme = "JwtBearer";
|
||||||
}).AddJwtBearer("JwtBearer", options =>
|
})
|
||||||
|
// Local JWT Bearer authentication (username/password)
|
||||||
|
.AddJwtBearer("JwtBearer", options =>
|
||||||
{
|
{
|
||||||
options.Audience = configuration["Authentication:JwtBearer:Audience"];
|
options.Audience = configuration["Authentication:JwtBearer:Audience"];
|
||||||
|
|
||||||
@@ -49,6 +53,19 @@ namespace ASPBaseOIDC.Web.Host.Startup
|
|||||||
{
|
{
|
||||||
OnMessageReceived = QueryStringTokenResolver
|
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": {
|
"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": {
|
"App": {
|
||||||
"ServerRootAddress": "https://localhost:44311/",
|
"ServerRootAddress": "https://localhost:44311/",
|
||||||
|
|||||||
Reference in New Issue
Block a user