Add whitelisting service + update the jwt valid audience
This commit is contained in:
@@ -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)
|
||||
|
||||
231
src/Managing.Api/Controllers/WhitelistController.cs
Normal file
231
src/Managing.Api/Controllers/WhitelistController.cs
Normal 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; }
|
||||
}
|
||||
|
||||
|
||||
79
src/Managing.Api/Models/PrivyWebhookDto.cs
Normal file
79
src/Managing.Api/Models/PrivyWebhookDto.cs
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user