Add whitelisting service + update the jwt valid audience

This commit is contained in:
2025-11-07 19:38:33 +07:00
parent 5578d272fa
commit 21110cd771
17 changed files with 2575 additions and 7 deletions

View File

@@ -24,7 +24,10 @@ public class JwtUtils : IJwtUtils
_secret = config.GetValue<string>("Jwt:Secret")
?? throw new InvalidOperationException("JWT secret is not configured.");
_issuer = config.GetValue<string>("Authentication:Schemes:Bearer:ValidIssuer");
_audience = config.GetValue<string>("Authentication:Schemes:Bearer:ValidAudiences");
// Get first audience from array (tokens are generated with a single audience)
var audiences = config.GetSection("Authentication:Schemes:Bearer:ValidAudiences")
.Get<string[]>() ?? Array.Empty<string>();
_audience = audiences.Length > 0 ? audiences[0] : null;
}
public string GenerateJwtToken(User user, string publicAddress)
@@ -37,7 +40,7 @@ public class JwtUtils : IJwtUtils
Subject = new ClaimsIdentity(new[] { new Claim("address", publicAddress) }),
Expires = DateTime.UtcNow.AddDays(15),
Issuer = _issuer, // Include issuer if configured
Audience = _audience, // Include audience if configured
Audience = _audience, // Include audience if configured (uses first from array)
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature)

View File

@@ -0,0 +1,231 @@
using Managing.Api.Models;
using Managing.Application.Abstractions.Services;
using Managing.Application.Shared;
using Managing.Domain.Whitelist;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Managing.Api.Controllers;
/// <summary>
/// Controller for managing whitelist accounts (Privy wallets).
/// Requires admin authorization for all operations.
/// </summary>
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class WhitelistController : BaseController
{
private readonly IWhitelistService _whitelistService;
private readonly IAdminConfigurationService _adminService;
private readonly ILogger<WhitelistController> _logger;
public WhitelistController(
IUserService userService,
IWhitelistService whitelistService,
IAdminConfigurationService adminService,
ILogger<WhitelistController> logger)
: base(userService)
{
_whitelistService = whitelistService;
_adminService = adminService;
_logger = logger;
}
/// <summary>
/// Gets a paginated list of whitelist accounts with optional search filters.
/// </summary>
/// <param name="pageNumber">Page number (1-based)</param>
/// <param name="pageSize">Page size (1-100)</param>
/// <param name="searchExternalEthereumAccount">Optional search term for external Ethereum account</param>
/// <param name="searchTwitterAccount">Optional search term for Twitter account</param>
/// <returns>Paginated list of whitelist accounts</returns>
[HttpGet]
public async Task<ActionResult<PaginatedWhitelistAccountsResponse>> GetWhitelistAccounts(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? searchExternalEthereumAccount = null,
[FromQuery] string? searchTwitterAccount = null)
{
var user = await GetUser();
if (!_adminService.IsUserAdmin(user.Name))
{
_logger.LogWarning("User {UserName} attempted to list whitelist accounts without admin privileges", user.Name);
return Forbid("Only admin users can list whitelist accounts");
}
try
{
var (accounts, totalCount) = await _whitelistService.GetPaginatedAsync(
pageNumber,
pageSize,
searchExternalEthereumAccount,
searchTwitterAccount);
return Ok(new PaginatedWhitelistAccountsResponse
{
Accounts = accounts,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing whitelist accounts for admin user {UserName}", user.Name);
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Sets the IsWhitelisted status to true for a selected account.
/// </summary>
/// <param name="id">The ID of the account to whitelist</param>
/// <returns>The updated account</returns>
[HttpPost("{id}/whitelist")]
public async Task<ActionResult<WhitelistAccount>> SetWhitelisted(int id)
{
var user = await GetUser();
if (!_adminService.IsUserAdmin(user.Name))
{
_logger.LogWarning("User {UserName} attempted to set whitelisted status without admin privileges", user.Name);
return Forbid("Only admin users can set whitelisted status");
}
try
{
var updatedCount = await _whitelistService.SetIsWhitelistedAsync(new[] { id }, true);
if (updatedCount == 0)
{
return NotFound(new { error = $"Whitelist account with Id {id} not found" });
}
_logger.LogInformation("Admin user {UserName} set IsWhitelisted=true for account {Id}",
user.Name, id);
// Fetch the updated account to return it
var updatedAccount = await _whitelistService.GetByIdAsync(id);
if (updatedAccount == null)
{
return NotFound(new { error = $"Whitelist account with Id {id} not found after update" });
}
return Ok(updatedAccount);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error setting whitelisted status for admin user {UserName}", user.Name);
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Receives Privy webhook for wallet creation events.
/// This endpoint should be called by Privy when a wallet is created.
/// </summary>
/// <param name="webhook">The Privy webhook payload</param>
/// <returns>The created or updated whitelist account</returns>
[HttpPost("webhook")]
[AllowAnonymous] // Webhook endpoint - consider adding webhook signature verification
public async Task<ActionResult<WhitelistAccount>> ProcessPrivyWebhook(
[FromBody] PrivyWebhookDto webhook)
{
try
{
// Validate webhook type
if (webhook.Type != "user.wallet_created")
{
_logger.LogWarning("Received webhook with unexpected type: {Type}", webhook.Type);
return BadRequest(new { error = $"Unexpected webhook type: {webhook.Type}" });
}
// Validate required fields
if (string.IsNullOrWhiteSpace(webhook.User?.Id))
{
return BadRequest(new { error = "User ID is required" });
}
if (string.IsNullOrWhiteSpace(webhook.Wallet?.Address))
{
return BadRequest(new { error = "Wallet address is required" });
}
// Validate linked accounts
if (webhook.User.LinkedAccounts == null || !webhook.User.LinkedAccounts.Any())
{
return BadRequest(new { error = "At least one linked account is required" });
}
if (webhook.User.LinkedAccounts.Count > 1)
{
_logger.LogWarning("Webhook received with {Count} linked accounts, expected 1. Using first account.",
webhook.User.LinkedAccounts.Count);
}
// Get the first linked account (as per requirement)
var linkedAccount = webhook.User.LinkedAccounts.First();
// Determine ExternalEthereumAccount or TwitterAccount based on linked account type
string? externalEthereumAccount = null;
string? twitterAccount = null;
var accountType = linkedAccount.Type?.ToLowerInvariant();
switch (accountType)
{
case "ethereum":
case "wallet":
case "evm":
externalEthereumAccount = linkedAccount.Address;
break;
case "twitter":
case "twitter_oauth":
twitterAccount = linkedAccount.Address;
break;
default:
_logger.LogWarning("Unknown linked account type: {Type}, address: {Address}",
linkedAccount.Type, linkedAccount.Address);
// Could be email or other types - we'll just log it
break;
}
// Process the webhook
var whitelistAccount = await _whitelistService.ProcessPrivyWebhookAsync(
webhook.User.Id,
webhook.User.CreatedAt,
webhook.Wallet.Address,
externalEthereumAccount,
twitterAccount);
_logger.LogInformation("Privy webhook processed successfully - AccountId: {Id}, PrivyId: {PrivyId}",
whitelistAccount.Id, whitelistAccount.PrivyId);
return Ok(whitelistAccount);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Privy webhook");
SentrySdk.CaptureException(ex);
return StatusCode(500, new { error = ex.Message });
}
}
}
/// <summary>
/// Response model for paginated whitelist accounts.
/// </summary>
public class PaginatedWhitelistAccountsResponse
{
public IEnumerable<WhitelistAccount> Accounts { get; set; } = new List<WhitelistAccount>();
public int TotalCount { get; set; }
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalPages { get; set; }
}

View File

@@ -0,0 +1,79 @@
using System.Text.Json.Serialization;
namespace Managing.Api.Models;
/// <summary>
/// Privy webhook payload for wallet creation events.
/// </summary>
public class PrivyWebhookDto
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("user")]
public PrivyUserDto User { get; set; } = new();
[JsonPropertyName("wallet")]
public PrivyWalletDto Wallet { get; set; } = new();
}
/// <summary>
/// Privy user information from webhook.
/// </summary>
public class PrivyUserDto
{
[JsonPropertyName("created_at")]
public long CreatedAt { get; set; }
[JsonPropertyName("has_accepted_terms")]
public bool HasAcceptedTerms { get; set; }
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("is_guest")]
public bool IsGuest { get; set; }
[JsonPropertyName("linked_accounts")]
public List<PrivyLinkedAccountDto> LinkedAccounts { get; set; } = new();
[JsonPropertyName("mfa_methods")]
public List<object> MfaMethods { get; set; } = new();
}
/// <summary>
/// Privy linked account information from webhook.
/// </summary>
public class PrivyLinkedAccountDto
{
[JsonPropertyName("address")]
public string Address { get; set; } = string.Empty;
[JsonPropertyName("first_verified_at")]
public long? FirstVerifiedAt { get; set; }
[JsonPropertyName("latest_verified_at")]
public long? LatestVerifiedAt { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("verified_at")]
public long? VerifiedAt { get; set; }
}
/// <summary>
/// Privy wallet information from webhook.
/// </summary>
public class PrivyWalletDto
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("address")]
public string Address { get; set; } = string.Empty;
[JsonPropertyName("chain_type")]
public string ChainType { get; set; } = string.Empty;
}

View File

@@ -14,6 +14,7 @@ using Managing.Infrastructure.Databases.InfluxDb.Models;
using Managing.Infrastructure.Databases.PostgreSql;
using Managing.Infrastructure.Databases.PostgreSql.Configurations;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Server.Kestrel.Core;
@@ -223,7 +224,8 @@ if (jwtSecret.Length < 32)
// Get issuer and audience configuration
var validIssuer = builder.Configuration["Authentication:Schemes:Bearer:ValidIssuer"];
var validAudience = builder.Configuration["Authentication:Schemes:Bearer:ValidAudiences"];
var validAudiences = builder.Configuration.GetSection("Authentication:Schemes:Bearer:ValidAudiences")
.Get<string[]>() ?? Array.Empty<string>();
// Determine if validation should be enabled (enable in production, allow override via config)
var enableIssuerValidation = builder.Configuration.GetValue<bool>("Jwt:ValidateIssuer",
@@ -247,11 +249,11 @@ builder.Services
o.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = validIssuer,
ValidAudience = validAudience,
ValidAudiences = validAudiences.Length > 0 ? validAudiences : null,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
ValidateIssuerSigningKey = true,
ValidateIssuer = enableIssuerValidation && !string.IsNullOrWhiteSpace(validIssuer),
ValidateAudience = enableAudienceValidation && !string.IsNullOrWhiteSpace(validAudience),
ValidateAudience = enableAudienceValidation && validAudiences.Length > 0,
ValidateLifetime = true, // Explicitly validate token expiration
ClockSkew = clockSkew, // Configure clock skew tolerance
RequireExpirationTime = true, // Ensure tokens have expiration
@@ -275,6 +277,19 @@ builder.Services
var logger = context.HttpContext.RequestServices
.GetService<ILogger<Program>>();
// Check if the endpoint allows anonymous access
var endpoint = context.HttpContext.GetEndpoint();
var allowAnonymous = endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null;
// For anonymous endpoints with malformed tokens, skip authentication instead of failing
if (allowAnonymous && context.Exception is SecurityTokenMalformedException)
{
logger?.LogDebug("Skipping malformed token validation for anonymous endpoint: {Path}",
context.Request.Path);
context.NoResult(); // Skip authentication, don't fail
return Task.CompletedTask;
}
if (context.Exception is SecurityTokenExpiredException)
{
context.Response.Headers["Token-Expired"] = "true";

View File

@@ -44,14 +44,18 @@
"Cors": {
"AllowedOrigins": [
"https://app.kaigen.ai",
"https://api.kaigen.ai"
"https://api.kaigen.ai",
"https://web-ui.kai.managing.live"
]
},
"Authentication": {
"Schemes": {
"Bearer": {
"ValidIssuer": "https://api.kaigen.ai",
"ValidAudiences": "https://app.kaigen.ai"
"ValidAudiences": [
"https://app.kaigen.ai",
"https://web-ui.kai.managing.live"
]
}
}
},