@ -0,0 +1,242 @@ |
|||
# ABP Platform 10.2 RC Has Been Released |
|||
|
|||
We are happy to release [ABP](https://abp.io) version **10.2 RC** (Release Candidate). This blog post introduces the new features and important changes in this new version. |
|||
|
|||
Try this version and provide feedback for a more stable version of ABP v10.2! Thanks to you in advance. |
|||
|
|||
## Get Started with the 10.2 RC |
|||
|
|||
You can check the [Get Started page](https://abp.io/get-started) to see how to get started with ABP. You can either download [ABP Studio](https://abp.io/get-started#abp-studio-tab) (**recommended**, if you prefer a user-friendly GUI application - desktop application) or use the [ABP CLI](https://abp.io/docs/latest/cli). |
|||
|
|||
By default, ABP Studio uses stable versions to create solutions. Therefore, if you want to create a solution with a preview version, first you need to create a solution and then switch your solution to the preview version from the ABP Studio UI: |
|||
|
|||
 |
|||
|
|||
## Migration Guide |
|||
|
|||
There are a few breaking changes in this version that may affect your application. Please read the migration guide carefully, if you are upgrading from v10.1 or earlier: [ABP Version 10.2 Migration Guide](https://abp.io/docs/10.2/release-info/migration-guides/abp-10-2). |
|||
|
|||
## What's New with ABP v10.2? |
|||
|
|||
In this section, I will introduce some major features released in this version. |
|||
Here is a brief list of titles explained in the next sections: |
|||
|
|||
- Multi-Tenant Account Usage: Shared User Accounts |
|||
- Prevent Privilege Escalation: Assignment Restrictions for Roles and Permissions |
|||
- `ClientResourcePermissionValueProvider` for OAuth/OpenIddict |
|||
- Angular: Hybrid Localization Support |
|||
- Angular: Extensible Table Row Detail |
|||
- Angular: CMS Kit Module Features |
|||
- Blazor: Upgrade to Blazorise 2.0 |
|||
- Identity: Single Active Token Providers |
|||
- TickerQ Package Upgrade to 10.1.1 |
|||
- AI Management: MCP (Model Context Protocol) Support |
|||
- AI Management: RAG with File Upload |
|||
- AI Management: OpenAI-Compatible Chat Endpoint |
|||
- File Management: Resource-Based Authorization |
|||
|
|||
### Multi-Tenant Account Usage: Shared User Accounts |
|||
|
|||
ABP v10.2 introduces **Shared User Accounts**: a single user account can belong to multiple tenants, and the user can choose or switch the active tenant when signing in. This enables a "one account, multiple tenants" experience — for example, inviting the same email address into multiple tenants. |
|||
|
|||
When you use Shared User Accounts: |
|||
|
|||
- Username/email uniqueness becomes **global** (Host + all tenants) |
|||
- Users are prompted to select the tenant at login if they belong to multiple tenants |
|||
- Users can switch between tenants using the tenant switcher in the user menu |
|||
- Tenant administrators can invite existing or new users to join a tenant |
|||
|
|||
Enable shared accounts by configuring `UserSharingStrategy`: |
|||
|
|||
```csharp |
|||
Configure<AbpMultiTenancyOptions>(options => |
|||
{ |
|||
options.IsEnabled = true; |
|||
options.UserSharingStrategy = TenantUserSharingStrategy.Shared; |
|||
}); |
|||
``` |
|||
|
|||
> See the [Shared User Accounts](https://abp.io/docs/10.2/modules/account/shared-user-accounts) documentation for details. |
|||
|
|||
### Prevent Privilege Escalation: Assignment Restrictions for Roles and Permissions |
|||
|
|||
ABP v10.2 implements a unified **privilege escalation prevention** model to address security vulnerabilities where users could assign themselves or others roles or permissions they do not possess. |
|||
|
|||
**Role Assignment Restriction:** Users can only assign or remove roles they currently have. Users cannot add new roles to themselves (removal only) and cannot assign or remove roles they do not possess. |
|||
|
|||
**Permission Grant/Revoke Authorization:** Users can only grant or revoke permissions they currently have. Validation applies to both grant and revoke operations. |
|||
|
|||
**Incremental Permission Protection:** When updating user or role permissions, permissions the current user does not have are treated as non-editable and are preserved as-is during updates. |
|||
|
|||
Users with the `admin` role can assign any role and grant/revoke any permission. All validations are enforced on the backend — the UI is not a security boundary. |
|||
|
|||
> See [#24775](https://github.com/abpframework/abp/pull/24775) for more details. |
|||
|
|||
### `ClientResourcePermissionValueProvider` for OAuth/OpenIddict |
|||
|
|||
ABP v10.2 adds **ClientResourcePermissionValueProvider**, extending resource-based authorization to OAuth clients. When using IdentityServer or OpenIddict, clients can now have resource permissions aligned with the standard user and role permission model. |
|||
|
|||
This allows you to control which OAuth clients can access which resources, providing fine-grained authorization for API consumers. The implementation integrates with ABP's existing resource permission infrastructure. |
|||
|
|||
> See [#24515](https://github.com/abpframework/abp/pull/24515) for more details. |
|||
|
|||
### Angular: Hybrid Localization Support |
|||
|
|||
ABP v10.2 introduces **Hybrid Localization** for Angular applications, combining server-side and client-side localization strategies. This gives you flexibility in how translations are loaded and resolved — you can use server-provided localization, client-side fallbacks, or a mix of both. |
|||
|
|||
This feature is useful when you want to reduce initial load time, support offline scenarios, or have environment-specific localization behavior. The Angular packages have been updated to support the hybrid approach seamlessly. |
|||
|
|||
> See the [Hybrid Localization](https://abp.io/docs/10.2/framework/ui/angular/hybrid-localization) documentation and [#24731](https://github.com/abpframework/abp/pull/24731). |
|||
|
|||
### Angular: Extensible Table Row Detail |
|||
|
|||
ABP v10.2 adds the **ExtensibleTableRowDetailComponent** for expandable row details in extensible tables. You can now display additional information for each row in a collapsible detail section. |
|||
|
|||
The feature supports row detail templates via both direct input and content child component. It adds toggle logic and emits `rowDetailToggle` events, making it easy to customize the behavior and appearance of expandable rows in your data tables. |
|||
|
|||
> See [#24636](https://github.com/abpframework/abp/pull/24636) for more details. |
|||
|
|||
### Angular: CMS Kit Module Features |
|||
|
|||
ABP v10.2 brings **CMS Kit features to Angular**, completing the cross-platform UI coverage for the CMS Kit module. The Angular implementation includes: Blogs, Blog Posts, Comments, Menus, Pages, Tags, Global Resources, and CMS Settings. |
|||
|
|||
Together with the CMS Kit Pro Angular implementation (FAQ, Newsletters, Page Feedbacks, Polls, Url forwarding), ABP now provides full Angular UI coverage for both the open-source CMS Kit and CMS Kit Pro modules. |
|||
|
|||
> See [#24234](https://github.com/abpframework/abp/pull/24234) for more details. |
|||
|
|||
### Blazor: Upgrade to Blazorise 2.0 |
|||
|
|||
ABP v10.2 upgrades the [Blazorise](https://blazorise.com/) library to **version 2.0** for Blazor UI. If you are upgrading your project to v10.2 RC, please ensure that all Blazorise-related packages are updated to v2.0 in your application. |
|||
|
|||
Blazorise 2.0 includes various improvements and changes. Please refer to the [Blazorise 2.0 Release Notes](https://blazorise.com/news/release-notes/200) and the [ABP Blazorise 2.0 Migration Guide](https://abp.io/docs/10.2/release-info/migration-guides/blazorise-2-0-migration) for upgrade instructions. |
|||
|
|||
> See [#24906](https://github.com/abpframework/abp/pull/24906) for more details. |
|||
|
|||
### Identity: Single Active Token Providers |
|||
|
|||
ABP v10.2 introduces a **single active token** policy for password reset, email confirmation, and change-email flows. Three new token providers are available: `AbpPasswordResetTokenProvider`, `AbpEmailConfirmationTokenProvider`, and `AbpChangeEmailTokenProvider`. |
|||
|
|||
When a new token is generated, it invalidates any previously issued tokens for that purpose. This improves security by ensuring that only the most recently issued token is valid. Token lifespan can be customized via the respective options classes for each provider. |
|||
|
|||
> See [#24926](https://github.com/abpframework/abp/pull/24926) for more details. |
|||
|
|||
### TickerQ Package Upgrade to 10.1.1 |
|||
|
|||
**If you are using the TickerQ integration packages** (`Volo.Abp.TickerQ`, `Volo.Abp.BackgroundJobs.TickerQ`, or `Volo.Abp.BackgroundWorkers.TickerQ`), you need to apply breaking changes when upgrading to ABP 10.2. TickerQ has been upgraded from 2.5.3 to 10.1.1, which only targets .NET 10.0 and contains several API changes. |
|||
|
|||
Key changes include: |
|||
|
|||
- `UseAbpTickerQ` moved from `IApplicationBuilder` to `IHost` — use `context.GetHost().UseAbpTickerQ()` in your module |
|||
- Entity types renamed: `TimeTicker` → `TimeTickerEntity`, `CronTicker` → `CronTickerEntity` |
|||
- Scheduler and dashboard configuration APIs have changed |
|||
- New helpers: `context.GetHost()`, `GetWebApplication()`, `GetEndpointRouteBuilder()` |
|||
|
|||
> **Important:** Do **not** resolve `IHost` from `context.ServiceProvider.GetRequiredService<IHost>()`. Always use `context.GetHost()`. See the [ABP Version 10.2 Migration Guide](https://abp.io/docs/10.2/release-info/migration-guides/abp-10-2) for the complete list of changes. |
|||
|
|||
### AI Management: MCP (Model Context Protocol) Support |
|||
|
|||
_This is a **PRO** feature available for ABP Commercial customers._ |
|||
|
|||
The [AI Management Module](https://abp.io/docs/10.2/modules/ai-management) now supports [MCP (Model Context Protocol)](https://modelcontextprotocol.io/), enabling AI workspaces to use external MCP servers as tools. MCP allows AI models to interact with external services, databases, APIs, and more through a standardized protocol. |
|||
|
|||
 |
|||
|
|||
You can create and manage MCP servers via the AI Management UI. Each MCP server supports one of the following transport types: **Stdio** (runs a local command), **SSE** (Server-Sent Events), or **StreamableHttp**. For HTTP-based transports, you can configure authentication (API Key, Bearer token, or custom headers). Once MCP servers are defined, you can associate them with workspaces. When a workspace has MCP servers associated, the AI model can invoke tools from those servers during chat conversations — tool calls and results are displayed in the chat interface. |
|||
|
|||
You can test the connection to an MCP server after creating it to verify connectivity and list available tools before use: |
|||
|
|||
 |
|||
|
|||
When a workspace has MCP servers associated, the AI model can invoke tools from those servers during chat conversations. Tool calls and results are displayed in the chat interface. |
|||
|
|||
 |
|||
|
|||
> See the [AI Management documentation](https://abp.io/docs/10.2/modules/ai-management#mcp-servers) for details. |
|||
|
|||
### AI Management: RAG with File Upload |
|||
|
|||
_This is a **PRO** feature available for ABP Commercial customers._ |
|||
|
|||
The AI Management module supports **RAG (Retrieval-Augmented Generation)** with file upload, which enables workspaces to answer questions based on the content of uploaded documents. When RAG is configured, the AI model searches the uploaded documents for relevant information before generating a response. |
|||
|
|||
To enable RAG, configure an **embedder** (e.g., OpenAI, Ollama) and a **vector store** (e.g., PgVector) on the workspace: |
|||
|
|||
| Embedder | Vector Store | |
|||
| --- | --- | |
|||
|  |  | |
|||
|
|||
You can then upload documents (PDF, Markdown, or text files, max 10 MB) through the workspace management UI. Uploaded documents are automatically processed — their content is chunked, embedded, and stored in the configured vector store: |
|||
|
|||
 |
|||
|
|||
When you ask questions in the chat interface, the AI model uses the uploaded documents as context for accurate, grounded responses. |
|||
|
|||
> See the [AI Management — RAG with File Upload](https://abp.io/docs/10.2/modules/ai-management#rag-with-file-upload) documentation for configuration details. |
|||
|
|||
### AI Management: OpenAI-Compatible Chat Endpoint |
|||
|
|||
_This is a **PRO** feature available for ABP Commercial customers._ |
|||
|
|||
The AI Management module exposes an **OpenAI-compatible REST API** at the `/v1` path. This allows any application or tool that supports the OpenAI API format — such as [AnythingLLM](https://anythingllm.com/), [Open WebUI](https://openwebui.com/), [Dify](https://dify.ai/), or custom scripts using the OpenAI SDK — to connect directly to your AI Management instance. |
|||
|
|||
**Example configuration from AnythingLLM**: |
|||
|
|||
 |
|||
|
|||
Each AI Management **workspace** appears as a selectable model in the client application. The workspace's configured AI provider handles the actual inference transparently. Available endpoints include `/v1/chat/completions`, `/v1/models`, `/v1/embeddings`, `/v1/files`, and more. All endpoints require authentication via a Bearer token in the `Authorization` header. |
|||
|
|||
> See the [AI Management — OpenAI-Compatible API](https://abp.io/docs/10.2/modules/ai-management#openai-compatible-api) documentation for usage examples. |
|||
|
|||
### File Management: Resource-Based Authorization |
|||
|
|||
_This is a **PRO** feature available for ABP Commercial customers._ |
|||
|
|||
The **File Management Module** now supports **resource-based authorization**. You can control access to individual files and folders per user, role, or client. Permissions can be granted at the resource level via the UI, and the feature integrates with ABP's resource permission infrastructure. |
|||
|
|||
 |
|||
|
|||
This feature is **implemented for all three supported UIs: MVC/Razor Pages, Blazor, and Angular**, providing a consistent experience across your application regardless of the UI framework you use. |
|||
|
|||
### Other Improvements and Enhancements |
|||
|
|||
- **Angular signal APIs**: ABP Angular packages migrated to signal queries, output functions, and signal input functions for alignment with Angular 21 ([#24765](https://github.com/abpframework/abp/pull/24765), [#24766](https://github.com/abpframework/abp/pull/24766), [#24777](https://github.com/abpframework/abp/pull/24777)). |
|||
- **Angular Vitest**: ABP Angular templates now use Vitest as the default testing framework instead of Karma/Jasmine ([#24725](https://github.com/abpframework/abp/pull/24725)). |
|||
- **Ambient auditing**: Programmatic disable/enable of auditing via `IAuditingHelper.DisableAuditing()` and `IsAuditingEnabled()` ([#24718](https://github.com/abpframework/abp/pull/24718)). |
|||
- **Complex property auditing**: Entity History and ModifierId now support EF Core complex properties ([#24767](https://github.com/abpframework/abp/pull/24767)). |
|||
- **RabbitMQ correlation ID**: Correlation ID support added to RabbitMQ JobQueue for distributed tracing ([#24755](https://github.com/abpframework/abp/pull/24755)). |
|||
- **Concurrent config retrieval**: `MvcCachedApplicationConfigurationClient` now fetches configuration and localization concurrently for faster startup ([#24838](https://github.com/abpframework/abp/pull/24838)). |
|||
- **Environment localization fallback**: Angular can use `environment.defaultResourceName` when the backend does not provide it ([#24589](https://github.com/abpframework/abp/pull/24589)). |
|||
- **JS proxy namespace fix**: Resolved namespace mismatch for multi-segment company names in generated proxies ([#24877](https://github.com/abpframework/abp/pull/24877)). |
|||
- **Audit Logging max length**: Entity/property type full names increased to 512 characters to reduce truncation ([#24846](https://github.com/abpframework/abp/pull/24846)). |
|||
- **AI guidelines**: Cursor and Copilot AI guideline documents added for ABP development ([#24563](https://github.com/abpframework/abp/pull/24563), [#24593](https://github.com/abpframework/abp/pull/24593)). |
|||
|
|||
## Community News |
|||
|
|||
### New ABP Community Articles |
|||
|
|||
As always, exciting articles have been contributed by the ABP community. I will highlight some of them here: |
|||
|
|||
- [Enis Necipoğlu](https://abp.io/community/members/enisn) has published 2 new posts: |
|||
- [ABP Framework's Hidden Magic: Things That Just Work Without You Knowing](https://abp.io/community/articles/hidden-magic-things-that-just-work-without-you-knowing-vw6osmyt) |
|||
- [Implementing Multiple Global Query Filters with Entity Framework Core](https://abp.io/community/articles/implementing-multiple-global-query-filters-with-entity-ugnsmf6i) |
|||
- [Suhaib Mousa](https://abp.io/community/members/suhaib-mousa) has published 2 new posts: |
|||
- [.NET 11 Preview 1 Highlights: Faster Runtime, Smarter JIT, and AI-Ready Improvements](https://abp.io/community/articles/dotnet-11-preview-1-highlights-hspp3o5x) |
|||
- [TOON vs JSON for LLM Prompts in ABP: Token-Efficient Structured Context](https://abp.io/community/articles/toon-vs-json-b4rn2avd) |
|||
- [Fahri Gedik](https://abp.io/community/members/fahrigedik) has published 2 new posts: |
|||
- [Building a Multi-Agent AI System with A2A, MCP, and ADK in .NET](https://abp.io/community/articles/building-a-multiagent-ai-system-with-a2a-mcp-iefdehyx) |
|||
- [Async Chain of Persistence Pattern: Designing for Failure in Event-Driven Systems](https://abp.io/community/articles/async-chain-of-persistence-pattern-wzjuy4gl) |
|||
- [Alper Ebiçoğlu](https://abp.io/community/members/alper) has published 2 new posts: |
|||
- [NDC London 2026: From a Developer's Perspective and My Personal Notes about AI](https://abp.io/community/articles/ndc-london-2026-a-.net-conf-from-a-developers-perspective-07wp50yl) |
|||
- [Which Open-Source PDF Libraries Are Recently Popular? A Data-Driven Look At PDF Topic](https://abp.io/community/articles/which-opensource-pdf-libraries-are-recently-popular-a-g68q78it) |
|||
- [Stop Spam and Toxic Users in Your App with AI](https://abp.io/community/articles/stop-spam-and-toxic-users-in-your-app-with-ai-3i0xxh0y) by [Engincan Veske](https://abp.io/community/members/EngincanV) |
|||
- [How AI Is Changing Developers](https://abp.io/community/articles/how-ai-is-changing-developers-e8y4a85f) by [Liming Ma](https://abp.io/community/members/maliming) |
|||
- [JetBrains State of Developer Ecosystem Report 2025 — Key Insights](https://abp.io/community/articles/jetbrains-state-of-developer-ecosystem-report-2025-key-z0638q5e) by [Tarık Özdemir](https://abp.io/community/members/mtozdemir) |
|||
- [Integrating AI into ABP.IO Applications: The Complete Guide to Volo.Abp.AI and AI Management Module](https://abp.io/community/articles/integrating-ai-into-abp.io-applications-the-complete-guide-jc9fbjq0) by [Adnan Ali](https://abp.io/community/members/adnanaldaim) |
|||
|
|||
Thanks to the ABP Community for all the content they have published. You can also [post your ABP related (text or video) content](https://abp.io/community/posts/create) to the ABP Community. |
|||
|
|||
## Conclusion |
|||
|
|||
This version comes with some new features and a lot of enhancements to the existing features. You can see the [Road Map](https://abp.io/docs/10.2/release-info/road-map) documentation to learn about the release schedule and planned features for the next releases. Please try ABP v10.2 RC and provide feedback to help us release a more stable version. |
|||
|
|||
Thanks for being a part of this community! |
|||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 56 KiB |
@ -0,0 +1,227 @@ |
|||
# Automate Localhost Access for Expo: A Guide to Dynamic Cloudflare Tunnels & Dev Builds |
|||
|
|||
Every mobile developer eventually hits the "Localhost Wall." You have built a brilliant API on your machine, and your React Native app works perfectly in the iOS Simulator or Android Emulator. But the moment you pick up a physical device to test real-world performance or camera features, everything breaks. |
|||
|
|||
### The Problem: Why Your Phone Can’t See localhost |
|||
|
|||
When you run a backend server on your computer, localhost refers to the "loopback" address and essentially, the computer talking to itself. Your physical iPhone or Android device is a separate node on the network. From its perspective, localhost is itself, not your development machine. Without a direct bridge, your mobile app is shouting into a void, unable to reach the API sitting just inches away on your desk. |
|||
|
|||
### The Conflict: The Fragility of Local IP Addresses |
|||
|
|||
The traditional workaround is to find the local IP address of your device and hardcode it into your app. However, this approach has many obstacles that make it difficult to use: |
|||
|
|||
- **Network Volatility:** Your router might assign you a new IP address tomorrow, forcing you to update your code constantly. |
|||
- **The SSL Headache:** Modern mobile operating systems and many OAuth providers (like Google or Auth0) strictly require **HTTPS**. Running a local development server with valid SSL certificates is a notorious configuration nightmare. |
|||
- **Broken OAuth flows:** Most authentication providers refuse to redirect to a non-secure `http` address or a random local IP, effectively locking you out of testing login features on a real device. |
|||
|
|||
### The Solution: Cloudflare Tunnel as a Secure Bridge |
|||
|
|||
This is where **Cloudflare Tunnel** changes the game. Instead of poking holes in your firewall or wrestling with self-signed certificates, Cloudflare Tunnel creates a secure, outbound-only connection between your local machine and the Cloudflare edge. |
|||
|
|||
It provides you with a **public, HTTPS-enabled URL** (e.g., `https://random-word.trycloudflare.com`) that automatically points to your local port. To your mobile device, your local backend looks like a standard, secure production API. It bypasses network restrictions, satisfies SSL requirements, and—when paired with a simple automation script—makes "localhost" development on physical devices completely seamless. |
|||
|
|||
### 1. Architecture Overview |
|||
|
|||
In order to understand why this setup is so effective, it is better to visualize the data flow. Traditionally, your mobile device would try to ping your laptop directly over Wi-Fi that is often blocked by firewalls or complicated by internal IP routing. |
|||
|
|||
#### Workflow Summary: The Secure "Middleman" |
|||
|
|||
The Cloudflare Tunnel acts as a persistent, encrypted bridge between your local environment and the public internet. Here is how the traffic flows in a standard development session: |
|||
|
|||
1. **The Connector:** You run a small `cloudflared` daemon on your development machine. It establishes an **outbound** connection to Cloudflare’s nearest edge server. Because it is outbound, you don't need to open any ports on your home or office router. |
|||
2. **The Public Endpoint:** Cloudflare provides a temporary, unique HTTPS URL (e.g., `https://example-tunnel.trycloudflare.com`). This URL is globally accessible. |
|||
3. **The Mobile Request:** Your React Native app that is running on a physical iPhone or Android sends an API request to that HTTPS URL. To the phone, this looks like any other secure production website. |
|||
4. **The Local Handoff:** Cloudflare receives the request and "tunnels" it down the active connection to your machine. The `cloudflared` tool then forwards that request to your local backend whether it's running on `.NET` at port `44358`, `Node.js` at `3000`, or `Rails` at `3000`. |
|||
5. **The Response:** Your backend processes the request and sends the data back through the same tunnel to the phone. |
|||
|
|||
By sitting in the middle, Cloudflare handles the **SSL termination** and the **Global Routing**, ensuring your backend is reachable regardless of whether your phone is on the same Wi-Fi as your laptop. |
|||
|
|||
### 2. Prerequisites |
|||
|
|||
Before we bridge the gap between your mobile device and your local machine, ensure your development environment is equipped with the following core components. |
|||
|
|||
To follow this guide, you will need: |
|||
|
|||
- **Node.js & Package Manager:** A stable version of Node.js (LTS recommended) and either **npm** or **yarn** to manage dependencies and run the automation scripts. |
|||
- **Expo CLI:** Ensure you have the latest version of `expo` installed globally or within your project. We will be using this to manage the development server and build the application. |
|||
- **Cloudflared CLI:** This is the critical "connector" tool from Cloudflare. You’ll need it installed on your local machine to establish the tunnel. |
|||
- *Quick Tip:* You don't need a paid Cloudflare account; the **Quick Tunnels** used in this guide are free and require no login. |
|||
- **A Running Backend API:** Your local server (e.g., .NET, Node.js, Django, or Rails) should be active and listening on a specific port (like `44358` or `3000`). |
|||
|
|||
### 3. Step-by-Step Implementation |
|||
|
|||
Now, let’s configure the automation that makes this workflow "set it and forget it." |
|||
|
|||
#### Phase A: Backend Configuration (The OAuth Handshake) |
|||
|
|||
Modern mobile authentication often relies on **OAuth 2.0** or **OpenID Connect**. For the login flow to succeed, your backend must "trust" the redirect URI sent by the mobile app. ABP applications are an example for such handshake. |
|||
|
|||
Even though we are using a Cloudflare URL for the API calls, the `auth-session` of Expo typically generates a `localhost` redirect for development. You must update your backend configuration (e.g., `appsettings.json` in a .NET TemplateTwo setup) to allow this: |
|||
|
|||
**File:** `src/YourProject.DbMigrator/appsettings.json` |
|||
|
|||
```json |
|||
{ |
|||
"OpenIddict": { |
|||
"Applications": { |
|||
"Mobile_App": { |
|||
"ClientId": "Mobile_App", |
|||
"RootUrl": "exp://localhost:19000" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
**Note:** By setting the `RootUrl` to `exp://localhost:19000`, you ensure that once the user authenticates via the tunnel's secure page, the mobile OS knows exactly how to hand the token back to your running Expo instance. |
|||
|
|||
#### Phase B: The "Magic" Script (Automating the Tunnel) |
|||
|
|||
The primary headache with free Cloudflare Tunnels is that they generate a **random URL** every time you restart the service. Manually copying `https://shiny-new-url.trycloudflare.com` into your frontend code every morning is a productivity killer. |
|||
|
|||
We solve this with a **Node.js automation script** that launches the tunnel, "listens" to the terminal output to find the new URL, and automatically injects it into your project's configuration. |
|||
|
|||
**File:** `react-native/scripts/tunnel.js` |
|||
|
|||
```js |
|||
const { spawn } = require('child_process'); |
|||
const fs = require('fs'); |
|||
const path = require('path'); |
|||
|
|||
// Target files for automation |
|||
const tunnelConfigFile = path.join(__dirname, '..', 'tunnel-config.json'); |
|||
const environmentFile = path.join(__dirname, '..', 'Environment.ts'); |
|||
|
|||
// 1. Launch the Cloudflare Tunnel pointing to your local API port |
|||
const cloudflared = spawn('cloudflared', ['tunnel', '--url', 'http://localhost:44358']); |
|||
|
|||
let domainCaptured = false; |
|||
|
|||
cloudflared.stdout.on('data', data => { |
|||
const output = data.toString(); |
|||
console.log(output); // Keep logs visible for debugging |
|||
|
|||
if (!domainCaptured) { |
|||
// 2. Regex to catch the dynamic "trycloudflare" URL |
|||
const urlMatch = output.match(/https:\/\/([a-z0-9-]+\.trycloudflare\.com)/); |
|||
if (urlMatch) { |
|||
const domain = urlMatch[1]; |
|||
|
|||
// 3. Save to a JSON file for the app to read |
|||
fs.writeFileSync(tunnelConfigFile, JSON.stringify({ domain }, null, 2)); |
|||
|
|||
// 4. Update the fallback value in Environment.ts directly |
|||
let envContent = fs.readFileSync(environmentFile, 'utf8'); |
|||
envContent = envContent.replace( |
|||
/let tunnelDomain = '[^']*'; \/\/ fallback/, |
|||
`let tunnelDomain = '${domain}'; // fallback`, |
|||
); |
|||
fs.writeFileSync(environmentFile, envContent, 'utf8'); |
|||
|
|||
console.log(`\n✅ Tunnel Synchronized: ${domain}`); |
|||
domainCaptured = true; |
|||
} |
|||
} |
|||
}); |
|||
``` |
|||
|
|||
By capturing the trycloudflare.com domain programmatically, we treat the tunnel like a dynamic environment variable. This ensures that your mobile app, your backend OAuth settings, and your API client stay in perfect sync without a single keystroke from you. |
|||
|
|||
#### Phase C: Environment Integration |
|||
|
|||
To make this work within your React Native code, your `Environment.ts` file needs to be "smart" enough to look for the generated config file. We use a `try/catch` block so the app doesn't crash if the tunnel isn't running. |
|||
|
|||
**File:** `react-native/Environment.ts` |
|||
|
|||
```tsx |
|||
let tunnelDomain = 'your-default-fallback.com'; // fallback |
|||
|
|||
try { |
|||
// Pull the latest domain from the script's output |
|||
const tunnelConfig = require('./tunnel-config.json'); |
|||
if (tunnelConfig?.domain) { |
|||
tunnelDomain = tunnelConfig.domain; |
|||
} |
|||
} catch (e) { |
|||
console.warn('⚠️ No active tunnel config found. Using fallback.'); |
|||
} |
|||
|
|||
const apiUrl = `https://${tunnelDomain}`; |
|||
|
|||
export const getEnvVars = () => { |
|||
return { |
|||
apiUrl, |
|||
// Other environment variables... |
|||
}; |
|||
}; |
|||
``` |
|||
|
|||
This setup creates a **"Single Source of Truth."** When you run the script, it updates `tunnel-config.json`, and your app instantly points to the correct secure endpoint. |
|||
|
|||
### 4. Integration with Expo Development Builds |
|||
|
|||
While you can technically use the standard **Expo Go** app for basic API testing, professional React Native workflows, especially those involving secure authentication and custom networking, rely on **Expo Development Builds**. |
|||
|
|||
#### Why Development Builds are Essential for This Workflow |
|||
|
|||
Standard Expo Go is a "one-size-fits-all" sandbox. However, as your app grows, it needs to behave more like a real, standalone binary. Development Builds are preferred for two main reasons: |
|||
|
|||
- **Custom URL Schemes:** For OAuth flows (like the one configured in Phase A), your app needs to handle specific deep links (e.g., `myapp://`). Expo Go has its own internal URL handling that can sometimes conflict with complex redirect logic. A Development Build allows you to define your own scheme, ensuring the Cloudflare-tunneled backend knows exactly where to send the user back after login. |
|||
- **Native Dependency Control:** If your app uses native modules for secure storage, biometrics, or advanced networking, Expo Go won't support them. A Development Build includes your project's specific native code while still giving you the "hot reloading" developer experience of Expo. |
|||
|
|||
#### Configuring the Build for Tunnelling |
|||
|
|||
To ensure your development build is ready for the Cloudflare tunnel, you'll typically use the `expo-dev-client` package. This transforms your app into a powerful developer tool that can switch between different local or tunneled environments on the fly. |
|||
|
|||
> **Pro Tip:** When you run `npx expo start`, your Development Build will look for the `apiUrl` we configured in `Environment.ts`. Since our script has already injected the Cloudflare URL, the physical device will connect to your local backend through the tunnel the moment the app loads. |
|||
|
|||
### 5. Execution Workflow |
|||
|
|||
To get your entire stack synchronized, follow this specific launch order. This ensures the tunnel is active and the configuration files are updated before the React Native app attempts to read them. |
|||
|
|||
#### Step 1: Start the Backend |
|||
|
|||
Fire up your API (e.g., `.NET`, `Node`, `Go`). Ensure it is listening on the port defined in your `tunnel.js` (e.g., `44358`). |
|||
|
|||
#### Step 2: Launch the Tunnel |
|||
|
|||
In a new terminal, run your automation script. |
|||
|
|||
Wait for the message: `✅ Tunnel Synchronized`. This confirms `tunnel-config.json` has been updated with the new `trycloudflare.com` domain. |
|||
|
|||
#### Step 3: Start Expo |
|||
|
|||
Finally, start your Expo development server: |
|||
|
|||
```bash |
|||
npx expo start |
|||
``` |
|||
|
|||
Open the app on your physical device by scanning the QR code. Your app is now communicating with your local machine over a secure, global HTTPS bridge. |
|||
|
|||
### 6. Troubleshooting & Best Practices |
|||
|
|||
Even with automation, networking can be finicky. If your app isn't reaching the API, check these common roadblocks: |
|||
|
|||
#### Common Pitfalls |
|||
|
|||
- **Port Mismatches:** Ensure the port in your `tunnel.js` script (e.g., `44358`) exactly matches the port your backend is listening on. If your backend uses HTTPS locally, ensure the tunnel command reflects that (e.g., `https://localhost:port`). |
|||
- **Firewall & Ghost Processes:** Sometimes a previous `cloudflared` process hangs in the background. If you can't start a new tunnel, kill existing processes or check if your local firewall is blocking `cloudflared` from making outbound connections. |
|||
- **Expired Sessions:** Free "Quick Tunnels" are temporary. If you leave your computer on overnight, the tunnel might disconnect. Simply restart the script to generate a fresh, synced URL. |
|||
|
|||
#### Security Note |
|||
|
|||
Cloudflare Tunnels create a **publicly accessible URL**. While the random strings in `trycloudflare.com` provide "security through obscurity," anyone with that link can hit your local API. |
|||
|
|||
- **Development Data Only:** Never use this setup with production databases or sensitive PII (Personally Identifiable Information). |
|||
- **Disable When Idle:** Close the tunnel terminal when you aren't actively developing to shut the "bridge" to your machine. |
|||
|
|||
### 7. Conclusion & Future-Proofing |
|||
|
|||
By replacing hardcoded local IPs with a dynamic Cloudflare Tunnel, you’ve transformed a clunky, manual process into a **"Set it and forget it"** workflow. You no longer have to worry about shifting Wi-Fi addresses or SSL certificate errors on physical devices. Your development environment now mirrors the behavior of a production app, providing more accurate testing and faster debugging. |
|||
|
|||
#### The Road to Production: EAS |
|||
|
|||
This tunneling strategy is the perfect companion for **EAS (Expo Application Services)**. As you move toward testing internal distributions, you can use these same environment patterns to point your EAS-built binaries to various staging or development endpoints. |
|||
|
|||
With a secure bridge and an automated config, you are no longer tethered to a simulator. Grab your phone, head to a coffee shop, and keep building—your backend is now globally (and securely) following you. |
|||
@ -0,0 +1,201 @@ |
|||
# Resource-Based Authorization in ABP Framework |
|||
|
|||
ABP has a built-in permission system that supports role-based access control (RBAC). You define permissions, assign them to roles, and assign roles to users — once a user logs in, they automatically have the corresponding access. This covers the vast majority of real-world scenarios and is simple, straightforward, and easy to maintain. |
|||
|
|||
However, there is one class of requirements it cannot handle: **different access rights for different instances of the same resource type**. |
|||
|
|||
Take a bookstore application as an example. You define a `Books.Edit` permission and assign it to an editor role, so every editor can modify every book. But reality is often more nuanced: |
|||
|
|||
- A specific book should only be editable by its assigned editor |
|||
- Certain books are only visible to specific users |
|||
- Different users have different levels of access to the same book |
|||
|
|||
Standard permissions cannot address this, because their granularity is the *permission type*, not a *specific record*. The traditional approach requires designing your own database tables, writing query logic, and building a management UI from scratch — all of which is costly. |
|||
|
|||
ABP Framework now ships with **Resource-Based Authorization** to solve exactly this problem. The core idea is to bind permissions to specific resource instances rather than just resource types. For example, you can grant a user permission to edit the price of *1984* specifically, while they have no access to any other book. |
|||
|
|||
More importantly, the entire permission management workflow is handled through a built-in UI dialog — **no custom code needed for the management side**. |
|||
|
|||
## How It Works |
|||
|
|||
Each resource instance (e.g. a book) can have its own permission management dialog. Users who hold the `ManagePermissions` permission can open it and grant or revoke access for users, roles, or OAuth clients — all from the UI. |
|||
|
|||
A **Permissions** action appears in each book's action menu: |
|||
|
|||
 |
|||
|
|||
Clicking it opens the resource permission management dialog for that specific book. You can see who currently has access and click **Add permission** to grant more: |
|||
|
|||
 |
|||
|
|||
The **Add permission** dialog lets you select a user, role, or OAuth client, then choose which permissions to grant: |
|||
|
|||
 |
|||
|
|||
After saving, the new entry appears in the list immediately. |
|||
|
|||
Each entry in the list also supports **Edit** and **Delete** actions. Clicking **Edit** opens the update dialog where you can adjust the granted permissions: |
|||
|
|||
 |
|||
|
|||
Clicking **Delete** shows a confirmation prompt — confirming removes all permissions for that user, role, or OAuth client on this book: |
|||
|
|||
 |
|||
|
|||
## Setting It Up |
|||
|
|||
To get this working, you need to define your resource permissions and wire up the dialog. |
|||
|
|||
### Defining Resource Permissions |
|||
|
|||
```csharp |
|||
public static class BookStorePermissions |
|||
{ |
|||
public const string GroupName = "BookStore"; |
|||
|
|||
public static class Books |
|||
{ |
|||
public const string Default = GroupName + ".Books"; |
|||
public const string ManagePermissions = Default + ".ManagePermissions"; |
|||
|
|||
public static class Resources |
|||
{ |
|||
public const string Name = "Acme.BookStore.Books.Book"; |
|||
public const string View = Name + ".View"; |
|||
public const string Edit = Name + ".Edit"; |
|||
public const string Delete = Name + ".Delete"; |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
```csharp |
|||
public override void Define(IPermissionDefinitionContext context) |
|||
{ |
|||
var group = context.AddGroup(BookStorePermissions.GroupName); |
|||
|
|||
var bookPermission = group.AddPermission(BookStorePermissions.Books.Default); |
|||
|
|||
// Users with this permission can open the resource permission dialog |
|||
bookPermission.AddChild(BookStorePermissions.Books.ManagePermissions); |
|||
|
|||
context.AddResourcePermission( |
|||
name: BookStorePermissions.Books.Resources.View, |
|||
resourceName: BookStorePermissions.Books.Resources.Name, |
|||
managementPermissionName: BookStorePermissions.Books.ManagePermissions |
|||
); |
|||
|
|||
context.AddResourcePermission( |
|||
name: BookStorePermissions.Books.Resources.Edit, |
|||
resourceName: BookStorePermissions.Books.Resources.Name, |
|||
managementPermissionName: BookStorePermissions.Books.ManagePermissions |
|||
); |
|||
|
|||
context.AddResourcePermission( |
|||
name: BookStorePermissions.Books.Resources.Delete, |
|||
resourceName: BookStorePermissions.Books.Resources.Name, |
|||
managementPermissionName: BookStorePermissions.Books.ManagePermissions |
|||
); |
|||
} |
|||
``` |
|||
|
|||
The `managementPermissionName` acts as a gate: only users who hold `ManagePermissions` will see the resource permission dialog for a book. |
|||
|
|||
### Wiring Up the Dialog (MVC) |
|||
|
|||
Add the required script to your page and open the dialog using `abp.ModalManager`: |
|||
|
|||
```html |
|||
@section scripts |
|||
{ |
|||
<abp-script src="/Pages/Books/Index.js"/> |
|||
<abp-script src="/Pages/AbpPermissionManagement/resource-permission-management-modal.js" /> |
|||
} |
|||
``` |
|||
|
|||
```javascript |
|||
var _permissionsModal = new abp.ModalManager({ |
|||
viewUrl: abp.appPath + 'AbpPermissionManagement/ResourcePermissionManagementModal', |
|||
modalClass: 'ResourcePermissionManagement' |
|||
}); |
|||
|
|||
function openPermissionsModal(bookId, bookName) { |
|||
_permissionsModal.open({ |
|||
resourceName: 'Acme.BookStore.Books.Book', |
|||
resourceKey: bookId, |
|||
resourceDisplayName: bookName |
|||
}); |
|||
} |
|||
``` |
|||
|
|||
> For Blazor and Angular applications, ABP provides the equivalent `ResourcePermissionManagementModal` component and `ResourcePermissionManagementComponent`. See the [Permission Management Module](https://abp.io/docs/latest/modules/permission-management) documentation for details. |
|||
|
|||
## Checking Permissions in Code |
|||
|
|||
The UI manages the permission assignments; the code enforces them at runtime. In your application service, use `AuthorizationService.CheckAsync` to verify that the current user holds a specific permission on a given resource instance. |
|||
|
|||
All ABP entities implement `IKeyedObject`, which the framework uses to extract the resource key automatically — so you can pass the entity object directly without building the key manually: |
|||
|
|||
```csharp |
|||
public virtual async Task<BookDto> GetAsync(Guid id) |
|||
{ |
|||
var book = await _bookRepository.GetAsync(id); |
|||
|
|||
// Throws AbpAuthorizationException if the current user has no View permission on this book |
|||
await AuthorizationService.CheckAsync(book, BookStorePermissions.Books.Resources.View); |
|||
|
|||
return ObjectMapper.Map<Book, BookDto>(book); |
|||
} |
|||
|
|||
public virtual async Task<BookDto> UpdateAsync(Guid id, UpdateBookDto input) |
|||
{ |
|||
var book = await _bookRepository.GetAsync(id); |
|||
|
|||
await AuthorizationService.CheckAsync(book, BookStorePermissions.Books.Resources.Edit); |
|||
|
|||
book.Name = input.Name; |
|||
await _bookRepository.UpdateAsync(book); |
|||
|
|||
return ObjectMapper.Map<Book, BookDto>(book); |
|||
} |
|||
``` |
|||
|
|||
If you want to check a permission without throwing an exception — for example, to conditionally show or hide a button — use `IsGrantedAsync` instead, which returns a `bool`: |
|||
|
|||
```csharp |
|||
var canEdit = await AuthorizationService.IsGrantedAsync(book, BookStorePermissions.Books.Resources.Edit); |
|||
``` |
|||
|
|||
## Don't Forget to Clean Up |
|||
|
|||
Every resource permission grant is stored as a record in the database. When a book is deleted, those records are not removed automatically — orphaned permission data accumulates over time. |
|||
|
|||
Make sure to clean up resource permissions whenever a resource is deleted: |
|||
|
|||
```csharp |
|||
public virtual async Task DeleteAsync(Guid id) |
|||
{ |
|||
await _bookRepository.DeleteAsync(id); |
|||
|
|||
// Clean up all resource permissions for this book |
|||
await _resourcePermissionManager.DeleteAsync( |
|||
resourceName: BookStorePermissions.Books.Resources.Name, |
|||
resourceKey: id.ToString() |
|||
); |
|||
} |
|||
``` |
|||
|
|||
## Summary |
|||
|
|||
Resource-Based Authorization fills the gap between "everyone can do this" and "only specific users can do this on specific resources." In practice, most of the work comes down to two things: |
|||
|
|||
- Define resource permissions and wire up the built-in UI dialog so administrators can assign access through the interface |
|||
- Call `AuthorizationService.CheckAsync` in your application services to enforce those permissions at runtime |
|||
|
|||
Storing permission grants, rendering the dialog, searching for users, roles, and OAuth clients — ABP handles all of that for you. |
|||
|
|||
## References |
|||
|
|||
- [Resource-Based Authorization](https://abp.io/docs/latest/framework/fundamentals/authorization/resource-based-authorization) |
|||
- [Authorization](https://abp.io/docs/latest/framework/fundamentals/authorization) |
|||
- [Permission Management Module](https://abp.io/docs/latest/modules/permission-management) |
|||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 76 KiB |
@ -0,0 +1,252 @@ |
|||
# Operation Rate Limiting in ABP Framework |
|||
|
|||
Almost every user-facing system eventually runs into the same problem: **some operations cannot be allowed to run without limits**. |
|||
|
|||
Sometimes it's a cost issue — sending an SMS costs money, and generating a report hammers the database. Sometimes it's security — a login endpoint with no attempt limit is an open invitation for brute-force attacks. And sometimes it's a matter of fairness — your paid plan says "up to 100 data exports per month," and you need to actually enforce that. |
|||
|
|||
What all these cases have in common is that the thing being limited isn't an HTTP request — it's a *business operation*, performed by a specific *who*, doing a specific *what*, against a specific *resource*. |
|||
|
|||
ASP.NET Core ships with a built-in [rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) that sits in the HTTP pipeline. It's excellent for broad API protection — throttling requests per IP to fend off bots or DDoS traffic. But it only sees HTTP requests. It can tell you how many requests came from an IP address; it cannot tell you: |
|||
|
|||
- **"How many verification codes has this phone number received today?"** The moment the user switches networks, the counter resets — completely useless |
|||
- **"How many reports has this user exported today?"** Switching from mobile to desktop gives them a fresh counter |
|||
- **"How many times has someone tried to log in as `alice`?"** An attacker rotating through dozens of IPs will never hit the per-IP limit |
|||
|
|||
There's another gap: some rate-limiting logic has no corresponding HTTP endpoint at all — it lives inside an application service method called by multiple endpoints, or triggered by a background job. HTTP middleware has no place to hook in. |
|||
|
|||
Real-world requirements tend to look like this: |
|||
|
|||
- The same phone number can receive at most 3 verification codes per hour, regardless of which device or IP the request comes from |
|||
- Each user can generate at most 2 monthly sales reports per day, because a single report query scans millions of records |
|||
- Login attempts are limited to 5 failures per username per 5 minutes, *and* 20 failures per IP per hour — two independent counters, both enforced simultaneously |
|||
- Free-tier users get 50 AI calls per month, paid users get 500 — this is a product-defined quota, not a security measure |
|||
- Your system integrates with an LLM provider (OpenAI, Azure OpenAI, etc.) where every call has a real dollar cost. Without per-user or per-tenant limits, a single user can exhaust your monthly budget overnight |
|||
|
|||
The pattern is clear: the identity being throttled is a **business identity** — a user, a phone number, a resource ID — not an IP address. And the action being throttled is a **business operation**, not an HTTP request. |
|||
|
|||
ABP Framework's **Operation Rate Limiting** module is built for exactly this. It lets you enforce limits directly in your application or domain layer, with full awareness of who is doing what. |
|||
|
|||
Add the package to your project: |
|||
|
|||
```bash |
|||
abp add-package Volo.Abp.OperationRateLimiting |
|||
``` |
|||
|
|||
> Operation Rate Limiting is available starting from **ABP Framework 10.3**. See the [pull request](https://github.com/abpframework/abp/pull/25024) for details. |
|||
|
|||
## Defining a Policy |
|||
|
|||
The model is straightforward: define a named policy in `ConfigureServices`, then call `CheckAsync` wherever you need to enforce it. |
|||
|
|||
Name your policies after the business action they protect — `"SendSmsCode"`, `"GenerateReport"`, `"CallAI"`. A clear name makes the intent obvious at the call site, and avoids the mystery of something like `"policy1"`. |
|||
|
|||
```csharp |
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
options.AddPolicy("SendSmsCode", policy => |
|||
{ |
|||
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
|||
.PartitionByParameter(); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
- `WithFixedWindow` sets the time window and maximum count — here, at most 1 call per minute |
|||
- `PartitionByParameter` means each distinct value you pass at call time (such as a phone number) gets its own independent counter |
|||
|
|||
Then inject `IOperationRateLimitingChecker` and call `CheckAsync` at the top of the method you want to protect: |
|||
|
|||
```csharp |
|||
public class SmsAppService : ApplicationService |
|||
{ |
|||
private readonly IOperationRateLimitingChecker _rateLimitChecker; |
|||
|
|||
public SmsAppService(IOperationRateLimitingChecker rateLimitChecker) |
|||
{ |
|||
_rateLimitChecker = rateLimitChecker; |
|||
} |
|||
|
|||
public async Task SendCodeAsync(string phoneNumber) |
|||
{ |
|||
await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber); |
|||
|
|||
// Limit not exceeded — proceed with sending the SMS |
|||
} |
|||
} |
|||
``` |
|||
|
|||
`CheckAsync` checks the current usage against the limit and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is already exceeded. If the check passes, it then increments the counter and proceeds. ABP's exception pipeline catches this automatically and returns a standard error response. Put `CheckAsync` first — the rate limit check is the gate, and everything else only runs if it passes. |
|||
|
|||
## Choosing a Partition Type |
|||
|
|||
The partition type controls **how counters are isolated from each other** — it's the most important decision when setting up a policy, because it determines *what dimension you're counting across*. |
|||
|
|||
Getting this wrong can make your rate limiting completely ineffective. Using `PartitionByClientIp` for SMS verification? An attacker just needs to switch networks. Using `PartitionByCurrentUser` for a login endpoint? There's no current user before login, so the counter has nowhere to land. |
|||
|
|||
- **`PartitionByParameter`** — uses the value you explicitly pass as the partition key. This is the most flexible option. Pass a phone number, an email address, a resource ID, or any business identifier you have at hand. It's the right choice whenever you know exactly what the "who" is. |
|||
- **`PartitionByCurrentUser`** — uses the authenticated user's ID, with no value to pass. Perfect for "each user gets N per day" scenarios where user identity is all you need. |
|||
- **`PartitionByClientIp`** — uses the client's IP address. Don't rely on this alone — it's too easy to rotate. Use it as a secondary layer alongside another partition type, as in the login example below. |
|||
- **`PartitionByEmail`** and **`PartitionByPhoneNumber`** — designed for pre-authentication flows where the user isn't logged in yet. They prefer the `Parameter` value you explicitly pass, and fall back to the current user's email or phone number if none is provided. |
|||
- **`PartitionBy`** — a custom async delegate that can produce any partition key you need. When the built-in options don't fit, you're free to implement whatever logic makes sense: look up a resource's owner in the database, derive a key from the user's subscription tier, partition by tenant — anything that returns a string. |
|||
|
|||
> The rule of thumb: partition by the identity of whoever's behavior you're trying to limit. |
|||
|
|||
## Combining Rules in One Policy |
|||
|
|||
A single rule covers most cases, but sometimes you need to enforce limits across multiple dimensions simultaneously. Login protection is the textbook example: throttling by username alone doesn't stop an attacker from targeting many accounts; throttling by IP alone doesn't stop an attacker with a botnet. You need both, at the same time. |
|||
|
|||
```csharp |
|||
options.AddPolicy("Login", policy => |
|||
{ |
|||
// Rule 1: at most 5 attempts per username per 5-minute window |
|||
policy.AddRule(rule => rule |
|||
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 5) |
|||
.PartitionByParameter()); |
|||
|
|||
// Rule 2: at most 20 attempts per IP per hour, counted independently |
|||
policy.AddRule(rule => rule |
|||
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20) |
|||
.PartitionByClientIp()); |
|||
}); |
|||
``` |
|||
|
|||
The two counters are completely independent. If `alice` fails 5 times, her account is locked — but other accounts from the same IP are unaffected. If an IP accumulates 20 failures, it's blocked — but `alice` can still be targeted from other IPs until their own counters fill up. |
|||
|
|||
When multiple rules are present, the module uses a two-phase approach: it checks all rules first, and only increments counters if every rule passes. This prevents a rule from consuming quota on a request that would have been rejected by another rule anyway. |
|||
|
|||
## Customizing Policies from Reusable Modules |
|||
|
|||
ABP modules (including your own) can ship with built-in rate limiting policies. For example, an Account module might define a `"Account.SendPasswordResetCode"` policy with conservative defaults that make sense for most applications. When you need different rules in your specific application, you have two options. |
|||
|
|||
**Complete replacement with `AddPolicy`:** call `AddPolicy` with the same name and the second registration wins, replacing all rules from the module: |
|||
|
|||
```csharp |
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
options.AddPolicy("Account.SendPasswordResetCode", policy => |
|||
{ |
|||
policy.AddRule(rule => rule |
|||
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3) |
|||
.PartitionByEmail()); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
**Partial modification with `ConfigurePolicy`:** when you only want to tweak part of a policy — change the error code, add a secondary rule, or tighten the window — use `ConfigurePolicy`. The builder starts pre-populated with the module's existing rules, so you only express what changes. |
|||
|
|||
For example, keep the module's default rules but assign your own localized error code: |
|||
|
|||
```csharp |
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
options.ConfigurePolicy("Account.SendPasswordResetCode", policy => |
|||
{ |
|||
policy.WithErrorCode("MyApp:PasswordResetLimit"); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
Or add a secondary IP-based rule on top of what the module already defined, without touching it: |
|||
|
|||
```csharp |
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
options.ConfigurePolicy("Account.SendPasswordResetCode", policy => |
|||
{ |
|||
policy.AddRule(rule => rule |
|||
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20) |
|||
.PartitionByClientIp()); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
If you want a clean slate, call `ClearRules()` first and then define entirely new rules — this gives you the same result as `AddPolicy` but makes the intent explicit: |
|||
|
|||
```csharp |
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
options.ConfigurePolicy("Account.SendPasswordResetCode", policy => |
|||
{ |
|||
policy.ClearRules() |
|||
.WithFixedWindow(TimeSpan.FromMinutes(10), maxCount: 5) |
|||
.PartitionByEmail(); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
`ConfigurePolicy` throws if the policy name doesn't exist — which catches typos at startup rather than silently doing nothing. |
|||
|
|||
The general rule: use `AddPolicy` for full replacements, `ConfigurePolicy` for surgical modifications. |
|||
|
|||
## Beyond Just Checking |
|||
|
|||
Not every scenario calls for throwing an exception. `IOperationRateLimitingChecker` provides three additional methods for more nuanced control. |
|||
|
|||
**`IsAllowedAsync`** performs a read-only check — it returns `true` or `false` without touching any counter. The most common use case is UI pre-checking: when a user opens the "send verification code" page, check the limit first. If they've already hit it, disable the button and show a countdown immediately, rather than making them click and get an error. That's a meaningfully better experience. |
|||
|
|||
```csharp |
|||
var isAllowed = await _rateLimitChecker.IsAllowedAsync("SendSmsCode", phoneNumber); |
|||
``` |
|||
|
|||
**`GetStatusAsync`** also reads without incrementing, but returns richer data: `RemainingCount`, `RetryAfter`, and `CurrentCount`. This is what you need to build quota displays — "You have 2 exports remaining today" or "Please try again in 47 seconds" — which are far friendlier than a raw 429. |
|||
|
|||
```csharp |
|||
var status = await _rateLimitChecker.GetStatusAsync("SendSmsCode", phoneNumber); |
|||
// status.RemainingCount, status.RetryAfter, status.IsAllowed ... |
|||
``` |
|||
|
|||
**`ResetAsync`** clears the counter for a given policy and context. Useful in admin panels where support staff can manually unblock a user, or in test environments where you need to reset state between runs. |
|||
|
|||
```csharp |
|||
await _rateLimitChecker.ResetAsync("SendSmsCode", phoneNumber); |
|||
``` |
|||
|
|||
## When the Limit Is Hit |
|||
|
|||
When `CheckAsync` triggers, it throws `AbpOperationRateLimitingException`, which: |
|||
|
|||
- Inherits from `BusinessException` and maps to HTTP **429 Too Many Requests** |
|||
- Is handled automatically by ABP's exception pipeline |
|||
- Carries useful metadata: `RetryAfterSeconds`, `RemainingCount`, `MaxCount`, `CurrentCount` |
|||
|
|||
By default, the error code sent to the client is a generic one from the module. If you want each operation to produce its own localized message — "Too many verification code requests, please wait before trying again" instead of a generic error — assign a custom error code to the policy: |
|||
|
|||
```csharp |
|||
options.AddPolicy("SendSmsCode", policy => |
|||
{ |
|||
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
|||
.PartitionByParameter() |
|||
.WithErrorCode("App:SmsCodeLimit"); |
|||
}); |
|||
``` |
|||
|
|||
> For details on mapping error codes to localized messages, see [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling) in the ABP docs. |
|||
|
|||
## Turning It Off in Development |
|||
|
|||
Rate limiting and local development don't mix well. When you're iterating quickly and calling the same endpoint a dozen times to test something, getting blocked by a 429 every few seconds is genuinely painful. Disable the module in your development environment: |
|||
|
|||
```csharp |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
var hostEnvironment = context.Services.GetHostingEnvironment(); |
|||
|
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
if (hostEnvironment.IsDevelopment()) |
|||
{ |
|||
options.IsEnabled = false; |
|||
} |
|||
}); |
|||
} |
|||
``` |
|||
|
|||
## Summary |
|||
|
|||
ABP's Operation Rate Limiting fills the gap that ASP.NET Core's HTTP middleware can't: rate limiting with real awareness of *who* is doing *what*. Define a named policy, pick a time window, a max count, and a partition type. Call `CheckAsync` wherever you need it. Counter storage, distributed locking, and exception handling are all taken care of. |
|||
|
|||
## References |
|||
|
|||
- [Operation Rate Limiting](https://abp.io/docs/latest/framework/infrastructure/operation-rate-limiting) |
|||
- [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) |
|||
- [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling) |
|||
|
After Width: | Height: | Size: 126 KiB |
@ -0,0 +1,113 @@ |
|||
# Automatically Validate Your Documentation: How We Built a Tutorial Validator |
|||
|
|||
Writing a tutorial is difficult. Keeping technical documentation accurate over time is even harder. |
|||
If you maintain developer documentation, you probably know the problem: a tutorial that worked a few months ago can silently break after a framework update, dependency change, or a small missing line in a code snippet. |
|||
New developers follow the guide, encounter an error, and quickly lose trust in the documentation. |
|||
To solve this problem, we built the tutorial validator — an open-source AI-powered tutorial validator that automatically verifies whether a software tutorial actually works from start to finish. |
|||
Instead of manually reviewing documentation, the tutorial validator behaves like a real developer following your guide step by step. |
|||
It reads instructions, runs commands, writes files, executes the application, and verifies expected results. |
|||
We initially created it to automatically validate ABP Framework tutorials, then released it as an open-source tool so anyone can use it to test their own documentation. |
|||
|
|||
|
|||
 |
|||
|
|||
|
|||
## The Problem: Broken Tutorials in Technical Documentation |
|||
|
|||
Many documentation issues are difficult to catch during normal reviews. |
|||
Common problems include: |
|||
|
|||
- A command assumes a file already exists |
|||
|
|||
- A code snippet misses a namespace or import |
|||
|
|||
- A tutorial step relies on hidden context |
|||
|
|||
- An endpoint is expected to respond but fails |
|||
|
|||
- A dependency version changed and breaks the project |
|||
|
|||
|
|||
Traditional proofreading tools only check grammar or wording. |
|||
**The tutorial validator focuses on execution correctness.** |
|||
It treats tutorials like testable workflows, ensuring that every step works exactly as written. |
|||
|
|||
## How the Tutorial Validator Works? |
|||
|
|||
the tutorial validator validates tutorials using a three-stage pipeline: |
|||
|
|||
1. **Analyst**: Scrapes tutorial pages and converts instructions into a structured test plan |
|||
2. **Executor**: Follows the plan step by step in a clean environment |
|||
3. **Reporter**: Produces a clear result summary and optional notifications |
|||
|
|||
 |
|||
|
|||
It identifies commands, code edits, HTTP requests, and expected outcomes. |
|||
The key idea is simple: if a developer would need to do it, the validator does it too. |
|||
That includes running terminal commands, editing files, checking HTTP responses, and validating build outcomes. |
|||
|
|||
 |
|||
|
|||
## Why Automated Tutorial Validation Matters? |
|||
|
|||
The tutorial validator is designed for practical documentation quality, not just technical experimentation. |
|||
|
|||
- **Catches real-world breakages early** before readers report them |
|||
- **Creates repeatable validation** instead of one-off manual checks |
|||
- **Works well in teams** through report outputs, logs, and CI-friendly behavior |
|||
- **Supports different strictness levels** with developer personas (`junior`, `mid`, `senior`) |
|||
|
|||
For example, `junior` and `mid` personas are great for spotting unclear documentation, while `senior` helps identify issues an experienced developer could work around. |
|||
|
|||
## Built for ABP, Open for Everyone |
|||
|
|||
Although TutorialValidator was originally built to validate **ABP Framework tutorials**, it works with **any publicly accessible software tutorial**. |
|||
|
|||
It supports validating any publicly accessible software tutorial and can run in: |
|||
|
|||
- **Docker mode** for clean, isolated execution (recommended) |
|||
- **Local mode** for faster feedback when your environment is already prepared |
|||
|
|||
It also supports multiple AI providers, including OpenAI, Azure OpenAI, and OpenAI-compatible endpoints. |
|||
|
|||
## Open Source and Easily Extensible |
|||
|
|||
The tutorial validator is designed with a modular architecture. |
|||
The project consists of multiple focused components: |
|||
|
|||
- **Core** – shared models and contracts |
|||
- **Analyst** – tutorial scraping and step extraction |
|||
- **Executor** – step-by-step execution engine |
|||
- **Orchestrator** – workflow coordination |
|||
- **Reporter** – notifications and result summaries |
|||
|
|||
This architecture makes it easy to extend the validator with: |
|||
|
|||
- new step types |
|||
- additional AI providers |
|||
- custom reporting integrations |
|||
|
|||
This architecture keeps the project easy to understand and extend. Teams can add new step types, plugins, or reporting channels based on their own workflow. |
|||
|
|||
## Final Thoughts |
|||
|
|||
Documentation is a critical part of the product experience. |
|||
When tutorials break, developer trust breaks too. |
|||
TutorialValidator helps teams move from: |
|||
|
|||
> We believe this tutorial works 🙄 |
|||
|
|||
to |
|||
|
|||
> We verified this tutorial works ✅ |
|||
|
|||
If your team maintains **technical tutorials, developer guides, or framework documentation**, automated tutorial validation can provide a powerful safety net. |
|||
|
|||
Documentation is part of the product experience. When tutorials fail, trust fails. |
|||
If your team maintains technical tutorials, this project can give you a practical safety net and a repeatable quality process. |
|||
|
|||
--- |
|||
|
|||
You can find the source-code of the tutorial validator at this repo 👉 https://github.com/abpframework/tutorial-validator |
|||
|
|||
We would love to hear your feedback, ideas and waiting PRs to improve this application. |
|||
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 10 KiB |
@ -0,0 +1,185 @@ |
|||
# Secure Client Authentication with private_key_jwt in ABP 10.3 |
|||
|
|||
If you've built a confidential client with ABP's OpenIddict module, you know the drill: create an application in the management UI, set a `client_id`, generate a `client_secret`, and paste that secret into your client's `appsettings.json` or environment variables. It works. It's familiar. And for a lot of projects, it's perfectly fine. |
|||
|
|||
But `client_secret` is a **shared secret** — and shared secrets carry an uncomfortable truth: the same value exists in two places at once. The authorization server stores a hash of it in the database, and your client stores the raw value in configuration. That means two potential leak points. Worse, the secret has no inherent identity. Anyone who obtains the string can impersonate your client and the server has no way to tell the difference. |
|||
|
|||
For many teams, this tradeoff is acceptable. But certain scenarios make it hard to ignore: |
|||
|
|||
- **Microservice-to-microservice calls**: A backend mesh of a dozen services, each with its own `client_secret` scattered across deployment configs and CI/CD pipelines. Rotating them across environments without missing one becomes a coordination problem. |
|||
- **Multi-tenant SaaS platforms**: Every tenant's client application deserves truly isolated credentials. With shared secrets, the database holds hashed copies for all tenants — a breach of that table is a breach of everyone's credentials. |
|||
- **Financial-grade API (FAPI) compliance**: Standards like [FAPI 2.0](https://openid.net/specs/fapi-2_0-security-profile.html) explicitly require asymmetric client authentication. `client_secret` doesn't make the cut. |
|||
- **Zero-trust architectures**: In a zero-trust model, identity must be cryptographically provable, not based on a string that can be copied and pasted. |
|||
|
|||
The underlying problem is that a shared secret is just a password. It can be stolen, replicated, and used without leaving a trace. The fix has existed in cryptography for decades: **asymmetric keys**. |
|||
|
|||
With asymmetric key authentication, the client generates a key pair. The public key is registered with the authorization server. The private key never leaves the client. Each time the client needs a token, it signs a short-lived JWT — called a _client assertion_ — with the private key. The server verifies the signature using the registered public key. There is no secret on the server side that could be used to forge a request, because the private key is never transmitted or stored remotely. |
|||
|
|||
This is exactly what the **`private_key_jwt`** client authentication method, defined in [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication), provides. ABP's OpenIddict module now supports it end-to-end: you register a **JSON Web Key Set (JWKS)** containing your public key through the application management UI (ABP Commercial), and your client authenticates using the corresponding private key. The key generation tooling (`abp generate-jwks`) ships as part of the open-source ABP CLI. |
|||
|
|||
> This feature is available starting from **ABP Framework 10.3**. |
|||
|
|||
## How It Works |
|||
|
|||
The flow is straightforward: |
|||
|
|||
1. The client holds an RSA key pair — **private key** (kept locally) and **public key** (registered on the authorization server as a JWKS). |
|||
2. On each token request, the client uses the private key to sign a JWT with a short expiry and a unique `jti` claim. |
|||
3. The authorization server verifies the signature against the registered public key and issues a token if it checks out. |
|||
|
|||
The private key never leaves the client. Even if someone obtains the authorization server's database, there's nothing there that can be used to generate a valid client assertion. |
|||
|
|||
## Generating a Key Pair |
|||
|
|||
ABP CLI includes a `generate-jwks` command that creates an RSA key pair in the right formats: |
|||
|
|||
```bash |
|||
abp generate-jwks |
|||
``` |
|||
|
|||
This produces two files in the current directory: |
|||
|
|||
- `jwks.json` — the public key in JWKS format, to be uploaded to the server |
|||
- `jwks-private.pem` — the private key in PKCS#8 PEM format, to be kept on the client |
|||
|
|||
You can customize the output directory, key size, and signing algorithm: |
|||
|
|||
```bash |
|||
abp generate-jwks --alg RS512 --key-size 4096 -o ./keys -f myapp |
|||
``` |
|||
|
|||
> Supported algorithms: `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`. The default is `RS256` with a 2048-bit key. |
|||
|
|||
The command also prints the contents of `jwks.json` to the console so you can copy it directly. |
|||
|
|||
## Registering the JWKS in the Management UI |
|||
|
|||
Open **OpenIddict → Applications** in the ABP admin panel and create or edit a confidential application (Client Type: `Confidential`). |
|||
|
|||
In the **Client authentication method** section, you'll find the new **JSON Web Key Set** field. |
|||
|
|||
 |
|||
|
|||
Paste the contents of `jwks.json` into the **JSON Web Key Set** field: |
|||
|
|||
```json |
|||
{ |
|||
"keys": [ |
|||
{ |
|||
"kty": "RSA", |
|||
"use": "sig", |
|||
"kid": "6444...", |
|||
"alg": "RS256", |
|||
"n": "tx...", |
|||
"e": "AQAB" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
Save the application. It's now configured for `private_key_jwt` authentication. You can set either `client_secret` or a JWKS, or both — ABP enforces that a confidential application always has at least one credential. |
|||
|
|||
## Requesting a Token with the Private Key |
|||
|
|||
On the client side, each token request requires building a _client assertion_ JWT signed with the private key. Here's a complete `client_credentials` example: |
|||
|
|||
```csharp |
|||
// Discover the authorization server endpoints (including the issuer URI). |
|||
var client = new HttpClient(); |
|||
var configuration = await client.GetDiscoveryDocumentAsync("https://your-auth-server/"); |
|||
|
|||
// Load the private key generated by `abp generate-jwks`. |
|||
using var rsaKey = RSA.Create(); |
|||
rsaKey.ImportFromPem(await File.ReadAllTextAsync("jwks-private.pem")); |
|||
|
|||
// Read the kid from jwks.json so it stays in sync with the server-registered public key. |
|||
string? signingKid = null; |
|||
if (File.Exists("jwks.json")) |
|||
{ |
|||
using var jwksDoc = JsonDocument.Parse(await File.ReadAllTextAsync("jwks.json")); |
|||
if (jwksDoc.RootElement.TryGetProperty("keys", out var keysElem) && |
|||
keysElem.GetArrayLength() > 0 && |
|||
keysElem[0].TryGetProperty("kid", out var kidElem)) |
|||
{ |
|||
signingKid = kidElem.GetString(); |
|||
} |
|||
} |
|||
|
|||
var signingKey = new RsaSecurityKey(rsaKey) { KeyId = signingKid }; |
|||
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256); |
|||
|
|||
// Build the client assertion JWT. |
|||
var now = DateTime.UtcNow; |
|||
var jwtHandler = new JsonWebTokenHandler(); |
|||
var clientAssertionToken = jwtHandler.CreateToken(new SecurityTokenDescriptor |
|||
{ |
|||
// OpenIddict requires typ = "client-authentication+jwt" for client assertion JWTs. |
|||
TokenType = "client-authentication+jwt", |
|||
Issuer = "MyClientId", |
|||
// aud must equal the authorization server's issuer URI from the discovery document, |
|||
// not the token endpoint URL. |
|||
Audience = configuration.Issuer, |
|||
Subject = new ClaimsIdentity(new[] |
|||
{ |
|||
new Claim(JwtRegisteredClaimNames.Sub, "MyClientId"), |
|||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), |
|||
}), |
|||
IssuedAt = now, |
|||
NotBefore = now, |
|||
Expires = now.AddMinutes(5), |
|||
SigningCredentials = signingCredentials, |
|||
}); |
|||
|
|||
// Request a token using the client_credentials flow. |
|||
var tokenResponse = await client.RequestClientCredentialsTokenAsync( |
|||
new ClientCredentialsTokenRequest |
|||
{ |
|||
Address = configuration.TokenEndpoint, |
|||
ClientId = "MyClientId", |
|||
ClientCredentialStyle = ClientCredentialStyle.PostBody, |
|||
ClientAssertion = new ClientAssertion |
|||
{ |
|||
Type = OidcConstants.ClientAssertionTypes.JwtBearer, |
|||
Value = clientAssertionToken, |
|||
}, |
|||
Scope = "MyAPI", |
|||
}); |
|||
``` |
|||
|
|||
A few things worth paying attention to: |
|||
|
|||
- **`TokenType`** must be `"client-authentication+jwt"`. OpenIddict rejects client assertion JWTs that don't carry this header. |
|||
- **`Audience`** must match the authorization server's issuer URI exactly — use `configuration.Issuer` from the discovery document, not the token endpoint URL. |
|||
- **`Jti`** must be unique per request to prevent replay attacks. |
|||
- Keep **`Expires`** short (five minutes or less). A client assertion is a one-time proof of identity, not a long-lived credential. |
|||
|
|||
This example uses [IdentityModel](https://github.com/IdentityModel/IdentityModel) for the token request helpers and [Microsoft.IdentityModel.JsonWebTokens](https://www.nuget.org/packages/Microsoft.IdentityModel.JsonWebTokens) for JWT creation. |
|||
|
|||
## Key Rotation Without Downtime |
|||
|
|||
One of the practical advantages of JWKS is that it can hold multiple public keys simultaneously. This makes **zero-downtime key rotation** straightforward: |
|||
|
|||
1. Run `abp generate-jwks` to produce a new key pair. |
|||
2. Append the new public key to the `keys` array in your existing `jwks.json` and update the JWKS in the management UI. |
|||
3. Switch the client to sign assertions with the new private key. |
|||
4. Once the transition is complete, remove the old public key from the JWKS. |
|||
|
|||
During the transition window, both the old and new public keys are registered on the server, so any in-flight requests signed with either key will still validate correctly. |
|||
|
|||
## Summary |
|||
|
|||
To use `private_key_jwt` authentication in an ABP Pro application: |
|||
|
|||
1. Run `abp generate-jwks` to generate an RSA key pair. |
|||
2. Paste the `jwks.json` contents into the **JSON Web Key Set** field in the OpenIddict application management UI. |
|||
3. On the client side, sign a short-lived _client assertion_ JWT with the private key — making sure to set the correct `typ`, `aud` (from the discovery document), and a unique `jti` — then use it to request a token. |
|||
|
|||
ABP handles public key storage and validation automatically. OpenIddict handles the signature verification on the token endpoint. As a developer, you only need to keep the private key file secure — there's no shared secret to synchronize between client and server. |
|||
|
|||
## References |
|||
|
|||
- [OpenID Connect Core — Client Authentication](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication) |
|||
- [RFC 7523 — JWT Profile for Client Authentication](https://datatracker.ietf.org/doc/html/rfc7523) |
|||
- [ABP OpenIddict Module Documentation](https://abp.io/docs/latest/modules/openiddict) |
|||
- [ABP CLI Documentation](https://abp.io/docs/latest/cli) |
|||
- [OpenIddict Documentation](https://documentation.openiddict.com/) |
|||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 72 KiB |
@ -0,0 +1,151 @@ |
|||
# One Endpoint, Many AI Clients: Turning ABP Workspaces into OpenAI-Compatible Models |
|||
|
|||
ABP's AI Management module already makes it easy to define and manage AI workspaces (provider, model, API key/base URL, system prompt, permissions, MCP tools, RAG settings, and more). With **ABP v10.2**, there is a major addition: you can now expose those workspaces through **OpenAI-compatible endpoints** under `/v1`. |
|||
|
|||
That changes the integration story in a practical way. Instead of wiring every external tool directly to a provider, you can point those tools to ABP and keep runtime decisions centralized in one place. |
|||
|
|||
In this post, we will walk through a practical setup with **AnythingLLM** and show why this pattern is useful in real projects. |
|||
|
|||
Before we get into the details, here's a quick look at the full flow in action: |
|||
|
|||
## See It in Action: AnythingLLM + ABP |
|||
|
|||
The demo below shows the full flow: connecting an OpenAI-compatible client to ABP, selecting a workspace-backed model, and sending a successful chat request through `/v1`. |
|||
|
|||
 |
|||
|
|||
## Why This Is a Big Deal |
|||
|
|||
Many teams end up with AI configuration spread across multiple clients and services. Updating providers, rotating keys, or changing model behavior can become operationally messy. |
|||
|
|||
With ABP in front of your AI traffic: |
|||
|
|||
- Clients keep speaking the familiar OpenAI contract. |
|||
- ABP resolves the requested `model` to a workspace. |
|||
- The workspace decides which provider/model settings are actually used. |
|||
|
|||
This gives you a clean split: standardized client integration outside, governed AI configuration inside. |
|||
|
|||
## Key Concept: Workspace = Model |
|||
|
|||
OpenAI-compatible clients send a `model` value. |
|||
In ABP AI Management, that `model` maps to a **workspace name**. |
|||
|
|||
**For example:** |
|||
|
|||
- Workspace name: `SupportAgent` |
|||
- Client request model: `SupportAgent` |
|||
|
|||
When the client calls `/v1/chat/completions` with `"model": "SupportAgent"`, ABP routes the request to that workspace and applies that workspace's provider (OpenAI, Ollama etc.) and model configuration. |
|||
|
|||
This is the main mental model to keep in mind while integrating any OpenAI-compatible tool with ABP. |
|||
|
|||
## Endpoints Exposed by ABP v10.2 |
|||
|
|||
The AI Management module exposes OpenAI-compatible REST endpoints at `/v1`. |
|||
|
|||
| Endpoint | Method | Description | |
|||
| ---------------------------- | ------ | ---------------------------------------------- | |
|||
| `/v1/chat/completions` | POST | Chat completions (streaming and non-streaming) | |
|||
| `/v1/completions` | POST | Legacy text completions | |
|||
| `/v1/models` | GET | List available models (workspaces) | |
|||
| `/v1/models/{modelId}` | GET | Get a single model (workspace) | |
|||
| `/v1/embeddings` | POST | Generate embeddings | |
|||
| `/v1/files` | GET | List files | |
|||
| `/v1/files` | POST | Upload a file | |
|||
| `/v1/files/{fileId}` | GET | Get file metadata | |
|||
| `/v1/files/{fileId}` | DELETE | Delete a file | |
|||
| `/v1/files/{fileId}/content` | GET | Download file content | |
|||
|
|||
All endpoints require `Authorization: Bearer <token>`. |
|||
|
|||
## Quick Setup with AnythingLLM |
|||
|
|||
Before configuration, ensure: |
|||
|
|||
1. AI Management is installed and running in your ABP app. |
|||
2. At least one workspace is created and **active**. |
|||
3. You have a valid Bearer token for your ABP application. |
|||
|
|||
### 1) Get an access token |
|||
|
|||
Use any valid token accepted by your app. In a demo-style setup, token retrieval can look like this: |
|||
|
|||
```bash |
|||
curl -X POST http://localhost:44337/connect/token \ |
|||
-d "grant_type=password&username=admin&password=1q2w3E*&client_id=DemoApp_API&client_secret=1q2w3e*&scope=DemoApp" |
|||
``` |
|||
|
|||
Use the returned `access_token` as the API key value in your OpenAI-compatible client. |
|||
|
|||
### 2) Configure AnythingLLM as Generic OpenAI |
|||
|
|||
In **AnythingLLM -> Settings -> LLM Preference**, select **Generic OpenAI** and set: |
|||
|
|||
| Setting | Value | |
|||
| -------------------- | --------------------------- | |
|||
| Base URL | `http://localhost:44337/v1` | |
|||
| API Key | `<access_token>` | |
|||
| Chat Model Selection | Select an active workspace | |
|||
|
|||
In most OpenAI-compatible UIs, the app adds `Bearer` automatically, so the API key field should contain only the raw token string. |
|||
|
|||
### 3) Optional: configure embeddings |
|||
|
|||
If you want RAG flows through ABP, go to **Settings -> Embedding Preference** and use the same Base URL/API key values. |
|||
Then select a workspace that has embedder settings configured. |
|||
|
|||
## Validate the Flow |
|||
|
|||
### List models (workspaces) |
|||
|
|||
```bash |
|||
curl http://localhost:44337/v1/models \ |
|||
-H "Authorization: Bearer <your-token>" |
|||
``` |
|||
|
|||
### Chat completion |
|||
|
|||
```bash |
|||
curl -X POST http://localhost:44337/v1/chat/completions \ |
|||
-H "Authorization: Bearer <your-token>" \ |
|||
-H "Content-Type: application/json" \ |
|||
-d '{ |
|||
"model": "MyWorkspace", |
|||
"messages": [ |
|||
{ "role": "user", "content": "Hello from ABP OpenAI-compatible endpoint!" } |
|||
] |
|||
}' |
|||
``` |
|||
|
|||
### Optional SDK check (Python) |
|||
|
|||
```python |
|||
from openai import OpenAI |
|||
|
|||
client = OpenAI( |
|||
base_url="http://localhost:44337/v1", |
|||
api_key="<your-token>" |
|||
) |
|||
|
|||
response = client.chat.completions.create( |
|||
model="MyWorkspace", |
|||
messages=[{"role": "user", "content": "Hello!"}] |
|||
) |
|||
|
|||
print(response.choices[0].message.content) |
|||
``` |
|||
|
|||
## Where This Fits in Real Projects |
|||
|
|||
This approach is a strong fit when you want to: |
|||
|
|||
- Keep ABP as the central control plane for AI workspaces. |
|||
- Let client tools integrate through a standard OpenAI contract. |
|||
- Switch providers or model settings without rewriting client-side integration. |
|||
|
|||
If your team uses multiple AI clients, this pattern keeps integration simple while preserving control where it matters. |
|||
|
|||
## Learn More |
|||
|
|||
- [ABP AI Management Documentation](https://abp.io/docs/10.2/modules/ai-management) |
|||
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 5.4 MiB |
@ -0,0 +1,167 @@ |
|||
# Shared User Accounts in ABP Multi-Tenancy |
|||
|
|||
Multi-tenancy is built on **isolation** — isolated data, isolated permissions, isolated users. ABP's default behavior has always followed this assumption: one user belongs to exactly one tenant. Clean, simple, no ambiguity. For most SaaS applications, that's exactly what you want. (The new `TenantUserSharingStrategy` enum formally names this default behavior `Isolated`.) |
|||
|
|||
But isolation is **the system's** concern, not **the user's**. In practice, people's work doesn't always line up neatly with tenant boundaries. |
|||
|
|||
Think about a financial consultant who works with three different companies — each one a tenant in your system. Under the Isolated model, she needs three separate accounts, three passwords. Forgot which password goes with which company? Good luck. Worse, the system sees three unrelated people — there's nothing linking those accounts to the same human being. |
|||
|
|||
This comes up more often than you'd think: |
|||
|
|||
- In a **corporate group**, an IT admin manages multiple subsidiaries, each running as its own tenant. Every day means logging out, logging back in with different credentials, over and over |
|||
- A **SaaS platform's ops team** needs to hop into different customer tenants to debug issues. Each time they create a throwaway account, then delete it — or just share one account and lose all audit trail |
|||
- Some users resort to email aliases (`alice+company1@example.com`) to work around uniqueness constraints — that's not a solution, that's a hack |
|||
|
|||
The common thread here: the user's **identity** is global, but their **working context** is per-tenant. The problem isn't a technical limitation — it's that the Isolated assumption ("one user, one tenant") simply doesn't hold in these scenarios. |
|||
|
|||
What's needed is not "one account per tenant" but "one account, multiple tenants." |
|||
|
|||
ABP's **Shared User Accounts** (`TenantUserSharingStrategy.Shared`) does exactly this. It makes user identity global and turns tenants into workspaces that a user can join and switch between — similar to how one person can belong to multiple workspaces in Slack. |
|||
|
|||
> This is a **commercial** feature, available starting from **ABP 10.2**, provided by the Account.Pro and Identity.Pro modules. |
|||
|
|||
## Enabling the Shared Strategy |
|||
|
|||
A single configuration is all it takes: |
|||
|
|||
```csharp |
|||
Configure<AbpMultiTenancyOptions>(options => |
|||
{ |
|||
options.IsEnabled = true; |
|||
options.UserSharingStrategy = TenantUserSharingStrategy.Shared; |
|||
}); |
|||
``` |
|||
|
|||
The most important behavior change after switching to Shared: **username and email uniqueness become global** instead of per-tenant. This follows naturally — if the same account needs to be recognized across tenants, its identifiers must be unique across the entire system. |
|||
|
|||
Security-related settings (2FA, account lockout, password policies, captcha, etc.) are also managed at the **Host** level. This makes sense too: if user identity is global, the security rules around it should be global as well. |
|||
|
|||
## One Account, Multiple Tenants |
|||
|
|||
With the Shared strategy enabled, the day-to-day user experience changes fundamentally. |
|||
|
|||
When a user is associated with only one tenant, the system recognizes it automatically and signs them in directly — the user doesn't even notice that tenants exist. When the user belongs to multiple tenants, the login flow presents a tenant selection screen after credentials are verified: |
|||
|
|||
 |
|||
|
|||
After signing into a tenant, a tenant switcher appears in the user menu — click it anytime to jump to another tenant without signing out. ABP re-issues the authentication ticket (with the new `TenantId` in the claims) on each switch, so the permission system is fully independent per tenant. |
|||
|
|||
 |
|||
|
|||
Users can also leave a tenant. Leaving doesn't delete the association record — it marks it as inactive. This preserves foreign key relationships with other entities. If the user is invited back later, the association is simply reactivated instead of recreated. |
|||
|
|||
Back to our earlier scenario: the financial consultant now has one account, one password. She picks which company to work in at login, switches between them during the day. The system knows it's the same person, and the audit log can trace her actions across every tenant. |
|||
|
|||
## Invitations |
|||
|
|||
Users don't just appear in a tenant — someone has to invite them. This is the core operation from the administrator's perspective. |
|||
|
|||
A tenant admin opens the invitation dialog, enters one or more email addresses (batch invitations are supported), and can pre-assign roles — so the user gets the right permissions the moment they join, no extra setup needed: |
|||
|
|||
 |
|||
|
|||
The invited person receives an email with a link. What happens next depends on whether they already have an account. |
|||
|
|||
If they **already have an account**, they see a confirmation page and can join the tenant with a single click: |
|||
|
|||
 |
|||
|
|||
If they **don't have an account yet**, the link takes them to a registration form. Once they register, they're automatically added to the tenant: |
|||
|
|||
 |
|||
|
|||
Admins can also manage pending invitations at any time — resend emails or revoke invitations. |
|||
|
|||
> The invitation feature is also available under the Isolated strategy, but invited users can only join a single tenant. |
|||
|
|||
## Setting Up a New Tenant |
|||
|
|||
There's a notable shift in how new tenants are bootstrapped. |
|||
|
|||
Under the Isolated model, creating a tenant typically seeds an `admin` user automatically. With Shared, this no longer happens — because users are global, and it doesn't make sense to create one out of thin air for a specific tenant. |
|||
|
|||
Instead, you create the tenant first, then invite someone in and grant them the admin role. |
|||
|
|||
 |
|||
|
|||
 |
|||
|
|||
This is a natural fit — the admin is just a global user who happens to hold the admin role in this particular tenant. |
|||
|
|||
## Where Do Newly Registered Users Go? |
|||
|
|||
Under the Shared strategy, self-registration runs into an interesting problem: the system doesn't know which tenant the user wants to join. Without being signed in, tenant context is usually determined by subdomain or a tenant switcher on the login page — but for a brand-new user, those signals might not exist at all. |
|||
|
|||
So ABP's approach is: **don't establish any tenant association at registration time**. A newly registered user doesn't belong to any tenant, and doesn't belong to the Host either — this is an entirely new state. ABP still lets these users sign in, change their password, and manage their account, but they can't access any permission-protected features within a tenant. |
|||
|
|||
`AbpIdentityPendingTenantUserOptions.Strategy` controls what happens in this "pending" state. |
|||
|
|||
**CreateTenant** — automatically creates a tenant for the new user. This fits the "sign up and get your own workspace" pattern, like how Slack or Notion handles registration: you register, the system spins up a workspace for you. |
|||
|
|||
```csharp |
|||
Configure<AbpIdentityPendingTenantUserOptions>(options => |
|||
{ |
|||
options.Strategy = AbpIdentityPendingTenantUserStrategy.CreateTenant; |
|||
}); |
|||
``` |
|||
|
|||
 |
|||
|
|||
**Inform** (the default) — shows a message telling the user to contact an administrator to join a tenant. This is the right choice for invite-only platforms where users must be brought in by an existing tenant admin. |
|||
|
|||
```csharp |
|||
Configure<AbpIdentityPendingTenantUserOptions>(options => |
|||
{ |
|||
options.Strategy = AbpIdentityPendingTenantUserStrategy.Inform; |
|||
}); |
|||
``` |
|||
|
|||
 |
|||
|
|||
There's also a **Redirect** strategy that sends the user to a custom URL for more complex flows. |
|||
|
|||
> See the [official documentation](https://abp.io/docs/latest/modules/account/shared-user-accounts) for full configuration details. |
|||
|
|||
## Database Considerations |
|||
|
|||
The Shared strategy introduces some mechanisms and constraints at the database level that are worth understanding. |
|||
|
|||
### Global Uniqueness: Enforced in Code, Not by Database Indexes |
|||
|
|||
Username and email uniqueness checks must span all tenants. ABP disables the tenant filter (`TenantFilter.Disable()`) during validation and searches globally for conflicts. |
|||
|
|||
A notable design choice here: **global uniqueness is enforced at the application level, not through database unique indexes**. The reason is practical — in a database-per-tenant setup, users live in separate physical databases, so a cross-database unique index simply isn't possible. Even in a shared database, soft-delete complicates unique indexes (you'd need a composite index on "username + deletion time"). So ABP handles this in application code instead. |
|||
|
|||
To keep things safe under concurrency — say two tenant admins invite the same email address at the same time — ABP uses a **distributed lock** to serialize uniqueness validation. This means your production environment needs a distributed lock provider configured (such as Redis). |
|||
|
|||
The uniqueness check goes beyond just "no duplicate usernames." ABP also checks for **cross-field conflicts**: a user's username can't match another user's email, and vice versa. This prevents identity confusion in edge cases. |
|||
|
|||
### Tenants with Separate Databases |
|||
|
|||
If some of your tenants use their own database (database-per-tenant), the Shared strategy requires extra attention. |
|||
|
|||
The login flow and tenant selection happen on the **Host side**. This means the Host database's `AbpUsers` table must contain records for all users — even those originally created in a tenant's separate database. ABP's approach is replication: it saves the primary user record in the Host context and creates a copy in the tenant context. In a shared-database setup, both records live in the same table; in a database-per-tenant setup, they live in different physical databases. Updates and deletes are kept in sync automatically. |
|||
|
|||
If your application uses social login or passkeys, the `AbpUserLogins` and `AbpUserPasskeys` tables also need to be synced in the Host database. |
|||
|
|||
### Migrating from the Isolated Strategy |
|||
|
|||
If you're moving an existing multi-tenant application from Isolated to Shared, ABP automatically runs a global uniqueness check when you switch the strategy and reports any conflicts. |
|||
|
|||
The most common conflict: the same email address registered as separate users in different tenants. You'll need to resolve these first — merge the accounts or change one side's email — before the Shared strategy can be enabled. |
|||
|
|||
## Summary |
|||
|
|||
ABP's Shared User Accounts addresses a real-world need in multi-tenant systems: one person working across multiple tenants. |
|||
|
|||
- One configuration switch to `TenantUserSharingStrategy.Shared` |
|||
- User experience: pick a tenant at login, switch between tenants anytime, one password for everything |
|||
- Admin experience: invite users by email, pre-assign roles on invitation |
|||
- Database notes: configure a distributed lock provider for production; tenants with separate databases need user records replicated in the Host database |
|||
|
|||
ABP takes care of global uniqueness validation, tenant association management, and login flow adaptation under the hood. |
|||
|
|||
## References |
|||
|
|||
- [Shared User Accounts](https://abp.io/docs/latest/modules/account/shared-user-accounts) |
|||
- [ABP Multi-Tenancy](https://abp.io/docs/latest/framework/architecture/multi-tenancy) |
|||
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 121 KiB |
@ -0,0 +1,564 @@ |
|||
````json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Learn how to use the Operation Rate Limiting module in ABP Framework to control the frequency of specific operations like SMS sending, login attempts, and resource-intensive tasks." |
|||
} |
|||
```` |
|||
|
|||
# Operation Rate Limiting |
|||
|
|||
ABP provides an operation rate limiting system that allows you to control the frequency of specific operations in your application. You may need operation rate limiting for several reasons: |
|||
|
|||
* Do not allow sending an SMS verification code to the same phone number more than 3 times in an hour. |
|||
* Do not allow generating a "monthly sales report" more than 2 times per day for each user (if generating the report is resource-intensive). |
|||
* Restrict login attempts per IP address to prevent brute-force attacks. |
|||
|
|||
> This is not for [ASP.NET Core's built-in rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) which works at the HTTP request pipeline level. This module works at the **application/domain code level** and is called explicitly from your services. See the [Combining with ASP.NET Core Rate Limiting](#combining-with-aspnet-core-rate-limiting) section for a comparison. |
|||
|
|||
## Installation |
|||
|
|||
You can open a command-line terminal and type the following command to install the [Volo.Abp.OperationRateLimiting](https://www.nuget.org/packages/Volo.Abp.OperationRateLimiting) package into your project: |
|||
|
|||
````bash |
|||
abp add-package Volo.Abp.OperationRateLimiting |
|||
```` |
|||
|
|||
> If you haven't done it yet, you first need to install the [ABP CLI](../../../cli). |
|||
|
|||
## Quick Start |
|||
|
|||
This section shows the basic usage of the operation rate limiting system with a simple example. |
|||
|
|||
### Defining a Policy |
|||
|
|||
First, define a rate limiting policy in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md): |
|||
|
|||
````csharp |
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
options.AddPolicy("SendSmsCode", policy => |
|||
{ |
|||
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
|||
.PartitionByParameter(); |
|||
}); |
|||
}); |
|||
```` |
|||
|
|||
* `"SendSmsCode"` is a unique name for this policy. |
|||
* `WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)` means at most **1 request per minute**. |
|||
* `PartitionByParameter()` means the counter is keyed by the parameter you pass at check time (e.g., a phone number), so different phone numbers have independent counters. |
|||
|
|||
### Checking the Limit |
|||
|
|||
Then inject `IOperationRateLimitingChecker` and call `CheckAsync` in your service: |
|||
|
|||
````csharp |
|||
public class SmsAppService : ApplicationService |
|||
{ |
|||
private readonly IOperationRateLimitingChecker _rateLimitChecker; |
|||
|
|||
public SmsAppService(IOperationRateLimitingChecker rateLimitChecker) |
|||
{ |
|||
_rateLimitChecker = rateLimitChecker; |
|||
} |
|||
|
|||
public async Task SendCodeAsync(string phoneNumber) |
|||
{ |
|||
await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber); |
|||
|
|||
// If we reach here, the limit was not exceeded. |
|||
// Send the SMS code... |
|||
} |
|||
} |
|||
```` |
|||
|
|||
* `CheckAsync` increments the counter and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is exceeded. |
|||
* Each phone number has its own counter because we used `PartitionByParameter()`. |
|||
* Passing `phoneNumber` directly is a shortcut for `new OperationRateLimitingContext { Parameter = phoneNumber }`. Extension methods are provided for all four methods (`CheckAsync`, `IsAllowedAsync`, `GetStatusAsync`, `ResetAsync`) when you only need to pass a `parameter` string. |
|||
|
|||
That's the basic usage. The following sections explain each concept in detail. |
|||
|
|||
## Defining Policies |
|||
|
|||
Policies are defined using `AbpOperationRateLimitingOptions` in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md). Each policy has a unique name, one or more rules, and a partition strategy. |
|||
|
|||
### Single-Rule Policies |
|||
|
|||
For simple scenarios, use the `WithFixedWindow` shortcut directly on the policy builder: |
|||
|
|||
````csharp |
|||
options.AddPolicy("SendSmsCode", policy => |
|||
{ |
|||
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
|||
.PartitionByParameter(); |
|||
}); |
|||
```` |
|||
|
|||
### Multi-Rule Policies |
|||
|
|||
Use `AddRule` to combine multiple rules. All rules are checked together (**AND** logic) — a request is allowed only when **all** rules pass: |
|||
|
|||
````csharp |
|||
options.AddPolicy("Login", policy => |
|||
{ |
|||
// Rule 1: Max 5 attempts per 5 minutes per username |
|||
policy.AddRule(rule => rule |
|||
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 5) |
|||
.PartitionByParameter()); |
|||
|
|||
// Rule 2: Max 20 attempts per hour per IP |
|||
policy.AddRule(rule => rule |
|||
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20) |
|||
.PartitionByClientIp()); |
|||
}); |
|||
```` |
|||
|
|||
> When multiple rules are present, the module uses a **two-phase check**: it first verifies all rules without incrementing counters, then increments only if all rules pass. This prevents wasted quota when one rule would block the request. |
|||
|
|||
### Overriding an Existing Policy |
|||
|
|||
If a reusable module (e.g., ABP's Account module) defines a policy with default rules, you have two ways to customize it in your own module's `ConfigureServices`. |
|||
|
|||
**Option 1 — Full replacement with `AddPolicy`:** |
|||
|
|||
Call `AddPolicy` with the same name. The last registration wins and completely replaces all rules: |
|||
|
|||
````csharp |
|||
// In your application module — runs after the Account module |
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
options.AddPolicy("Account.SendPasswordResetCode", policy => |
|||
{ |
|||
// Replaces all rules defined by the Account module for this policy |
|||
policy.AddRule(rule => rule |
|||
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3) |
|||
.PartitionByEmail()); |
|||
}); |
|||
}); |
|||
```` |
|||
|
|||
> `AddPolicy` stores policies in a dictionary keyed by name, so calling it again with the same name fully replaces the previous policy and all its rules. |
|||
|
|||
**Option 2 — Partial modification with `ConfigurePolicy`:** |
|||
|
|||
Use `ConfigurePolicy` to modify an existing policy without replacing it entirely. The builder is pre-populated with the existing rules, so you only need to express what changes: |
|||
|
|||
````csharp |
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
// Only override the error code, keeping the module's original rules |
|||
options.ConfigurePolicy("Account.SendPasswordResetCode", policy => |
|||
{ |
|||
policy.WithErrorCode("MyApp:SmsCodeLimit"); |
|||
}); |
|||
}); |
|||
```` |
|||
|
|||
You can also add a rule on top of the existing ones: |
|||
|
|||
````csharp |
|||
options.ConfigurePolicy("Account.SendPasswordResetCode", policy => |
|||
{ |
|||
// Keep the module's per-email rule and add a per-IP rule on top |
|||
policy.AddRule(rule => rule |
|||
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20) |
|||
.PartitionByClientIp()); |
|||
}); |
|||
```` |
|||
|
|||
Or clear all inherited rules first and define entirely new ones using `ClearRules()`: |
|||
|
|||
````csharp |
|||
options.ConfigurePolicy("Account.SendPasswordResetCode", policy => |
|||
{ |
|||
policy.ClearRules() |
|||
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3) |
|||
.PartitionByEmail(); |
|||
}); |
|||
```` |
|||
|
|||
`ConfigurePolicy` returns `AbpOperationRateLimitingOptions`, so you can chain multiple calls: |
|||
|
|||
````csharp |
|||
options |
|||
.ConfigurePolicy("Account.SendPasswordResetCode", p => p.WithErrorCode("MyApp:SmsLimit")) |
|||
.ConfigurePolicy("Account.Login", p => p.WithErrorCode("MyApp:LoginLimit")); |
|||
```` |
|||
|
|||
> `ConfigurePolicy` throws `AbpException` if the policy name is not found. Use `AddPolicy` first (in the module that owns the policy), then `ConfigurePolicy` in downstream modules to customize it. |
|||
|
|||
### Custom Error Code |
|||
|
|||
By default, the exception uses the error code `Volo.Abp.OperationRateLimiting:010001`. You can override it per policy: |
|||
|
|||
````csharp |
|||
options.AddPolicy("SendSmsCode", policy => |
|||
{ |
|||
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
|||
.PartitionByParameter() |
|||
.WithErrorCode("App:SmsCodeLimit"); |
|||
}); |
|||
```` |
|||
|
|||
## Partition Types |
|||
|
|||
Each rule must specify a **partition type** that determines how requests are grouped. Requests with different partition keys have independent counters. |
|||
|
|||
### PartitionByParameter |
|||
|
|||
Uses the `Parameter` value from the context you pass to `CheckAsync`: |
|||
|
|||
````csharp |
|||
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
|||
.PartitionByParameter(); |
|||
|
|||
// Each phone number has its own counter |
|||
await checker.CheckAsync("SendSmsCode", |
|||
new OperationRateLimitingContext { Parameter = phoneNumber }); |
|||
```` |
|||
|
|||
### PartitionByCurrentUser |
|||
|
|||
Uses `ICurrentUser.Id` as the partition key. The user must be authenticated: |
|||
|
|||
````csharp |
|||
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 10) |
|||
.PartitionByCurrentUser(); |
|||
```` |
|||
|
|||
> If you need to check rate limits for a specific user (e.g., admin checking another user's limit), use `PartitionByParameter()` and pass the user ID as the `Parameter`. |
|||
|
|||
### PartitionByCurrentTenant |
|||
|
|||
Uses `ICurrentTenant.Id` as the partition key. Uses `"host"` for the host side when no tenant is active: |
|||
|
|||
````csharp |
|||
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) |
|||
.PartitionByCurrentTenant(); |
|||
```` |
|||
|
|||
### PartitionByClientIp |
|||
|
|||
Uses `IWebClientInfoProvider.ClientIpAddress` as the partition key: |
|||
|
|||
````csharp |
|||
policy.WithFixedWindow(TimeSpan.FromMinutes(15), maxCount: 10) |
|||
.PartitionByClientIp(); |
|||
```` |
|||
|
|||
> This requires an ASP.NET Core environment. In non-web scenarios, the IP address cannot be determined and an exception will be thrown. Use `PartitionByParameter()` if you need to pass the IP explicitly. |
|||
|
|||
### PartitionByEmail |
|||
|
|||
Resolves from `context.Parameter` first, then falls back to `ICurrentUser.Email`: |
|||
|
|||
````csharp |
|||
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
|||
.PartitionByEmail(); |
|||
|
|||
// For unauthenticated users, pass the email explicitly: |
|||
await checker.CheckAsync("SendEmailCode", |
|||
new OperationRateLimitingContext { Parameter = email }); |
|||
```` |
|||
|
|||
### PartitionByPhoneNumber |
|||
|
|||
Works the same way as `PartitionByEmail`: resolves from `context.Parameter` first, then falls back to `ICurrentUser.PhoneNumber`. |
|||
|
|||
### Custom Partition (PartitionBy) |
|||
|
|||
You can provide a custom async function to generate the partition key. The async signature allows you to perform database queries or other I/O operations: |
|||
|
|||
````csharp |
|||
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) |
|||
.PartitionBy(ctx => Task.FromResult( |
|||
$"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}")); |
|||
```` |
|||
|
|||
## Multi-Tenancy |
|||
|
|||
By default, partition keys do not include tenant information — for partition types like `PartitionByParameter`, `PartitionByCurrentUser`, `PartitionByClientIp`, etc., counters are shared across tenants unless you call `WithMultiTenancy()`. Note that `PartitionByCurrentTenant()` is inherently per-tenant since the partition key is the tenant ID itself, and `PartitionByClientIp()` is typically kept global since the same IP should share a counter regardless of tenant. |
|||
|
|||
You can enable tenant isolation for a rule by calling `WithMultiTenancy()`: |
|||
|
|||
````csharp |
|||
policy.AddRule(rule => rule |
|||
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) |
|||
.WithMultiTenancy() |
|||
.PartitionByParameter()); |
|||
```` |
|||
|
|||
When multi-tenancy is enabled, the cache key includes the tenant ID, so each tenant has independent counters: |
|||
|
|||
* **Global key format:** `orl:{PolicyName}:{RuleKey}:{PartitionKey}` |
|||
* **Tenant-isolated key format:** `orl:t:{TenantId}:{PolicyName}:{RuleKey}:{PartitionKey}` |
|||
|
|||
## Checking the Limit |
|||
|
|||
Inject `IOperationRateLimitingChecker` to interact with rate limits. It provides four methods: |
|||
|
|||
### CheckAsync |
|||
|
|||
The primary method. It checks the rate limit and **increments the counter** if allowed. Throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is exceeded: |
|||
|
|||
````csharp |
|||
await checker.CheckAsync("SendSmsCode", |
|||
new OperationRateLimitingContext { Parameter = phoneNumber }); |
|||
```` |
|||
|
|||
### IsAllowedAsync |
|||
|
|||
A read-only check that returns `true` or `false` **without incrementing** the counter. Useful for UI pre-checks (e.g., disabling a button before the user clicks): |
|||
|
|||
````csharp |
|||
var isAllowed = await checker.IsAllowedAsync("SendSmsCode", |
|||
new OperationRateLimitingContext { Parameter = phoneNumber }); |
|||
```` |
|||
|
|||
### GetStatusAsync |
|||
|
|||
Returns detailed status information **without incrementing** the counter: |
|||
|
|||
````csharp |
|||
var status = await checker.GetStatusAsync("SendSmsCode", |
|||
new OperationRateLimitingContext { Parameter = phoneNumber }); |
|||
|
|||
// status.IsAllowed - whether the next request would be allowed |
|||
// status.RemainingCount - how many requests are left in this window |
|||
// status.RetryAfter - time until the window resets |
|||
// status.MaxCount - maximum allowed count |
|||
// status.CurrentCount - current usage count |
|||
```` |
|||
|
|||
### ResetAsync |
|||
|
|||
Resets the counter for a specific policy and context. This can be useful for administrative operations: |
|||
|
|||
````csharp |
|||
await checker.ResetAsync("SendSmsCode", |
|||
new OperationRateLimitingContext { Parameter = phoneNumber }); |
|||
```` |
|||
|
|||
## The Exception |
|||
|
|||
When a rate limit is exceeded, `CheckAsync` throws `AbpOperationRateLimitingException`. This exception: |
|||
|
|||
* Extends `BusinessException` and implements `IHasHttpStatusCode` with status code **429** (Too Many Requests). |
|||
* Is automatically handled by ABP's exception handling pipeline and serialized into the HTTP response. |
|||
|
|||
The exception uses one of two error codes depending on the policy type: |
|||
|
|||
| Error Code | Constant | When Used | |
|||
|---|---|---| |
|||
| `Volo.Abp.OperationRateLimiting:010001` | `AbpOperationRateLimitingErrorCodes.ExceedLimit` | Regular rate limit exceeded (has a retry-after window) | |
|||
| `Volo.Abp.OperationRateLimiting:010002` | `AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently` | Ban policy (`maxCount: 0`, permanently denied) | |
|||
|
|||
You can override the error code per policy using `WithErrorCode()`. When a custom code is set, it is always used regardless of the policy type. |
|||
|
|||
The exception includes the following data properties: |
|||
|
|||
| Key | Type | Description | |
|||
|-----|------|-------------| |
|||
| `PolicyName` | string | Name of the triggered policy | |
|||
| `MaxCount` | int | Maximum allowed count | |
|||
| `CurrentCount` | int | Current usage count | |
|||
| `RemainingCount` | int | Remaining allowed count | |
|||
| `RetryAfterSeconds` | int | Seconds until the window resets (`0` for ban policies) | |
|||
| `RetryAfterMinutes` | int | Minutes until the window resets, rounded down (`0` for ban policies) | |
|||
| `RetryAfter` | string | Localized retry-after description (e.g., "5 minutes"); absent for ban policies | |
|||
| `WindowDurationSeconds` | int | Total window duration in seconds | |
|||
| `WindowDescription` | string | Localized window description | |
|||
| `RuleDetails` | List | Per-rule details (for multi-rule policies) | |
|||
|
|||
## Configuration |
|||
|
|||
### AbpOperationRateLimitingOptions |
|||
|
|||
`AbpOperationRateLimitingOptions` is the main options class for the operation rate limiting system: |
|||
|
|||
````csharp |
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
options.IsEnabled = true; |
|||
options.LockTimeout = TimeSpan.FromSeconds(5); |
|||
}); |
|||
```` |
|||
|
|||
* **`IsEnabled`** (`bool`, default: `true`): Global switch to enable or disable rate limiting. When set to `false`, all `CheckAsync` calls pass through without checking. This is useful for disabling rate limiting in development (see [below](#disabling-in-development)). |
|||
* **`LockTimeout`** (`TimeSpan`, default: `5 seconds`): Timeout for acquiring the distributed lock during counter increment operations. |
|||
|
|||
## Advanced Usage |
|||
|
|||
### Disabling in Development |
|||
|
|||
You may want to disable rate limiting during development to avoid being blocked while testing: |
|||
|
|||
````csharp |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
var hostEnvironment = context.Services.GetHostingEnvironment(); |
|||
|
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
if (hostEnvironment.IsDevelopment()) |
|||
{ |
|||
options.IsEnabled = false; |
|||
} |
|||
}); |
|||
} |
|||
```` |
|||
|
|||
### Ban Policy (maxCount: 0) |
|||
|
|||
Setting `maxCount` to `0` creates a ban policy that permanently denies all requests regardless of the window duration. The `RetryAfter` value will be `null` since there is no window to wait for. The exception uses the error code `Volo.Abp.OperationRateLimiting:010002` (`AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently`) with the message "Operation rate limit exceeded. This request is permanently denied.": |
|||
|
|||
````csharp |
|||
options.AddPolicy("BlockedUser", policy => |
|||
{ |
|||
policy.WithFixedWindow(TimeSpan.FromHours(24), maxCount: 0) |
|||
.PartitionByParameter(); |
|||
}); |
|||
```` |
|||
|
|||
### Passing Extra Properties |
|||
|
|||
Use `ExtraProperties` on `OperationRateLimitingContext` to pass additional context data. These values are available in custom partition resolvers and are included in the exception data when the limit is exceeded: |
|||
|
|||
````csharp |
|||
await checker.CheckAsync("ApiCall", new OperationRateLimitingContext |
|||
{ |
|||
Parameter = apiEndpoint, |
|||
ExtraProperties = |
|||
{ |
|||
["DeviceId"] = deviceId, |
|||
["ClientVersion"] = clientVersion |
|||
} |
|||
}); |
|||
```` |
|||
|
|||
### Pre-checking Before Expensive Operations |
|||
|
|||
Use `IsAllowedAsync` or `GetStatusAsync` to check the limit **before** performing expensive work (e.g., validating input or querying the database): |
|||
|
|||
````csharp |
|||
public async Task<SendCodeResultDto> SendCodeAsync(string phoneNumber) |
|||
{ |
|||
var context = new OperationRateLimitingContext { Parameter = phoneNumber }; |
|||
|
|||
// Check limit before doing any work |
|||
var status = await _rateLimitChecker.GetStatusAsync("SendSmsCode", context); |
|||
|
|||
if (!status.IsAllowed) |
|||
{ |
|||
return new SendCodeResultDto |
|||
{ |
|||
Success = false, |
|||
RetryAfterSeconds = (int)(status.RetryAfter?.TotalSeconds ?? 0) |
|||
}; |
|||
} |
|||
|
|||
// Now do the actual work and increment the counter |
|||
await _rateLimitChecker.CheckAsync("SendSmsCode", context); |
|||
|
|||
await _smsSender.SendAsync(phoneNumber, GenerateCode()); |
|||
return new SendCodeResultDto { Success = true }; |
|||
} |
|||
```` |
|||
|
|||
> `IsAllowedAsync` and `GetStatusAsync` are read-only — they do not increment the counter. Only `CheckAsync` increments. |
|||
|
|||
### Checking on Behalf of Another User |
|||
|
|||
`PartitionByCurrentUser()`, `PartitionByCurrentTenant()`, and `PartitionByClientIp()` always resolve from their respective services (`ICurrentUser`, `ICurrentTenant`, `IWebClientInfoProvider`) and do not accept explicit overrides. This design avoids partition key conflicts in [composite policies](#multi-rule-policies) where `Parameter` is shared across all rules. |
|||
|
|||
If you need to check or enforce rate limits for a **specific user, tenant, or IP**, define the policy with `PartitionByParameter()` and pass the value explicitly: |
|||
|
|||
````csharp |
|||
// Policy definition: use PartitionByParameter for explicit control |
|||
options.AddPolicy("UserApiLimit", policy => |
|||
{ |
|||
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) |
|||
.PartitionByParameter(); |
|||
}); |
|||
```` |
|||
|
|||
````csharp |
|||
// Check current user's limit |
|||
await checker.CheckAsync("UserApiLimit", |
|||
new OperationRateLimitingContext { Parameter = CurrentUser.Id.ToString() }); |
|||
|
|||
// Admin checking another user's limit |
|||
await checker.CheckAsync("UserApiLimit", |
|||
new OperationRateLimitingContext { Parameter = targetUserId.ToString() }); |
|||
|
|||
// Check a specific IP in a background job |
|||
await checker.CheckAsync("UserApiLimit", |
|||
new OperationRateLimitingContext { Parameter = ipAddress }); |
|||
```` |
|||
|
|||
This approach gives you full flexibility while keeping the API simple — `PartitionByCurrentUser()` is a convenience shortcut for "always use the current authenticated user", and `PartitionByParameter()` is for "I want to specify the value explicitly". |
|||
|
|||
### Combining with ASP.NET Core Rate Limiting |
|||
|
|||
This module and ASP.NET Core's built-in [rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) serve different purposes and can be used together: |
|||
|
|||
| | ASP.NET Core Rate Limiting | Operation Rate Limiting | |
|||
|---|---|---| |
|||
| **Level** | HTTP request pipeline | Application/domain code | |
|||
| **Scope** | All incoming requests | Specific business operations | |
|||
| **Usage** | Middleware (automatic) | Explicit `CheckAsync` calls | |
|||
| **Typical use** | API throttling, DDoS protection | Business logic limits (SMS, reports) | |
|||
|
|||
A common pattern is to use ASP.NET Core middleware for broad API protection and this module for fine-grained business operation limits. |
|||
|
|||
## Extensibility |
|||
|
|||
### Custom Store |
|||
|
|||
The default store uses ABP's `IDistributedCache`. You can replace it by implementing `IOperationRateLimitingStore`: |
|||
|
|||
````csharp |
|||
public class MyCustomStore : IOperationRateLimitingStore, ITransientDependency |
|||
{ |
|||
public Task<OperationRateLimitingStoreResult> IncrementAsync( |
|||
string key, TimeSpan duration, int maxCount) |
|||
{ |
|||
// Your custom implementation (e.g., Redis Lua script for atomicity) |
|||
} |
|||
|
|||
public Task<OperationRateLimitingStoreResult> GetAsync( |
|||
string key, TimeSpan duration, int maxCount) |
|||
{ |
|||
// Read-only check |
|||
} |
|||
|
|||
public Task ResetAsync(string key) |
|||
{ |
|||
// Reset the counter |
|||
} |
|||
} |
|||
```` |
|||
|
|||
ABP's [dependency injection](../../fundamentals/dependency-injection.md) system will automatically use your implementation since it replaces the default one. |
|||
|
|||
### Custom Rule |
|||
|
|||
You can implement custom rate limiting algorithms (e.g., sliding window, token bucket) by implementing `IOperationRateLimitingRule` and registering it with `AddRule<TRule>()`: |
|||
|
|||
````csharp |
|||
policy.AddRule<MySlidingWindowRule>(); |
|||
```` |
|||
|
|||
### Custom Formatter |
|||
|
|||
Replace `IOperationRateLimitingFormatter` to customize how time durations are displayed in error messages (e.g., "5 minutes", "2 hours 30 minutes"). |
|||
|
|||
### Custom Policy Provider |
|||
|
|||
Replace `IOperationRateLimitingPolicyProvider` to load policies from a database or external configuration source instead of the in-memory options. |
|||
|
|||
## See Also |
|||
|
|||
* [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) |
|||
* [Distributed Caching](../fundamentals/caching.md) |
|||
* [Exception Handling](../fundamentals/exception-handling.md) |
|||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
@ -0,0 +1,22 @@ |
|||
using System; |
|||
using System.Reflection; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; |
|||
|
|||
public interface IXmlDocumentationProvider |
|||
{ |
|||
Task<string?> GetSummaryAsync(Type type); |
|||
|
|||
Task<string?> GetRemarksAsync(Type type); |
|||
|
|||
Task<string?> GetSummaryAsync(MethodInfo method); |
|||
|
|||
Task<string?> GetRemarksAsync(MethodInfo method); |
|||
|
|||
Task<string?> GetReturnsAsync(MethodInfo method); |
|||
|
|||
Task<string?> GetParameterSummaryAsync(MethodInfo method, string parameterName); |
|||
|
|||
Task<string?> GetSummaryAsync(PropertyInfo property); |
|||
} |
|||
@ -0,0 +1,231 @@ |
|||
using System; |
|||
using System.Collections.Concurrent; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Reflection; |
|||
using System.Text.RegularExpressions; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using System.Xml.Linq; |
|||
using System.Xml.XPath; |
|||
using Microsoft.Extensions.Logging; |
|||
using Microsoft.Extensions.Logging.Abstractions; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.AspNetCore.Mvc.ApiExploring; |
|||
|
|||
public class XmlDocumentationProvider : IXmlDocumentationProvider, ISingletonDependency |
|||
{ |
|||
public ILogger<XmlDocumentationProvider> Logger { get; set; } |
|||
|
|||
public XmlDocumentationProvider() |
|||
{ |
|||
Logger = NullLogger<XmlDocumentationProvider>.Instance; |
|||
} |
|||
|
|||
private static readonly Regex WhitespaceRegex = new(@"\s+", RegexOptions.Compiled); |
|||
|
|||
// Matches any remaining XML tags like <c>, <code>, <para>, <b>, etc.
|
|||
private static readonly Regex XmlTagRegex = new(@"<[^>]+>", RegexOptions.Compiled); |
|||
|
|||
// Matches <see cref="T:Foo.Bar"/>, <see langword="null"/>, <paramref name="x"/>, <typeparamref name="T"/>
|
|||
private static readonly Regex XmlRefTagRegex = new( |
|||
@"<(see|paramref|typeparamref)\s+(cref|name|langword)=""([TMFPE]:)?(?<display>[^""]+)""\s*/?>", |
|||
RegexOptions.Compiled); |
|||
|
|||
private readonly ConcurrentDictionary<Assembly, Lazy<Task<XDocument?>>> _xmlDocCache = new(); |
|||
|
|||
public virtual async Task<string?> GetSummaryAsync(Type type) |
|||
{ |
|||
var memberName = GetMemberNameForType(type); |
|||
return await GetDocumentationElementAsync(type.Assembly, memberName, "summary"); |
|||
} |
|||
|
|||
public virtual async Task<string?> GetRemarksAsync(Type type) |
|||
{ |
|||
var memberName = GetMemberNameForType(type); |
|||
return await GetDocumentationElementAsync(type.Assembly, memberName, "remarks"); |
|||
} |
|||
|
|||
public virtual async Task<string?> GetSummaryAsync(MethodInfo method) |
|||
{ |
|||
var memberName = GetMemberNameForMethod(method); |
|||
return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "summary"); |
|||
} |
|||
|
|||
public virtual async Task<string?> GetRemarksAsync(MethodInfo method) |
|||
{ |
|||
var memberName = GetMemberNameForMethod(method); |
|||
return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "remarks"); |
|||
} |
|||
|
|||
public virtual async Task<string?> GetReturnsAsync(MethodInfo method) |
|||
{ |
|||
var memberName = GetMemberNameForMethod(method); |
|||
return await GetDocumentationElementAsync(method.DeclaringType!.Assembly, memberName, "returns"); |
|||
} |
|||
|
|||
public virtual async Task<string?> GetParameterSummaryAsync(MethodInfo method, string parameterName) |
|||
{ |
|||
var memberName = GetMemberNameForMethod(method); |
|||
var doc = await LoadXmlDocumentationAsync(method.DeclaringType!.Assembly); |
|||
if (doc == null) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var memberNode = doc.XPathSelectElement($"//member[@name='{memberName}']"); |
|||
var paramNode = memberNode?.XPathSelectElement($"param[@name='{parameterName}']"); |
|||
return CleanXmlText(paramNode); |
|||
} |
|||
|
|||
public virtual async Task<string?> GetSummaryAsync(PropertyInfo property) |
|||
{ |
|||
var memberName = GetMemberNameForProperty(property); |
|||
return await GetDocumentationElementAsync(property.DeclaringType!.Assembly, memberName, "summary"); |
|||
} |
|||
|
|||
protected virtual async Task<string?> GetDocumentationElementAsync(Assembly assembly, string memberName, string elementName) |
|||
{ |
|||
var doc = await LoadXmlDocumentationAsync(assembly); |
|||
if (doc == null) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var memberNode = doc.XPathSelectElement($"//member[@name='{memberName}']"); |
|||
var element = memberNode?.Element(elementName); |
|||
return CleanXmlText(element); |
|||
} |
|||
|
|||
protected virtual Task<XDocument?> LoadXmlDocumentationAsync(Assembly assembly) |
|||
{ |
|||
return _xmlDocCache.GetOrAdd( |
|||
assembly, |
|||
asm => new Lazy<Task<XDocument?>>( |
|||
() => LoadXmlDocumentationFromDiskAsync(asm), |
|||
LazyThreadSafetyMode.ExecutionAndPublication) |
|||
).Value; |
|||
} |
|||
|
|||
protected virtual async Task<XDocument?> LoadXmlDocumentationFromDiskAsync(Assembly assembly) |
|||
{ |
|||
if (string.IsNullOrEmpty(assembly.Location)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var xmlFilePath = Path.ChangeExtension(assembly.Location, ".xml"); |
|||
if (!File.Exists(xmlFilePath)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
await using var stream = new FileStream(xmlFilePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true); |
|||
return await XDocument.LoadAsync(stream, LoadOptions.None, CancellationToken.None); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
Logger.LogWarning(ex, "Failed to load XML documentation from {XmlFilePath}.", xmlFilePath); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
private static string? CleanXmlText(XElement? element) |
|||
{ |
|||
if (element == null) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
// Convert to string first so we can process inline XML tags like <see cref="..."/>
|
|||
var raw = element.ToString(); |
|||
|
|||
// Strip the outer element tags (e.g. <summary>...</summary>)
|
|||
var start = raw.IndexOf('>') + 1; |
|||
var end = raw.LastIndexOf('<'); |
|||
if (start >= end) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var inner = raw[start..end]; |
|||
|
|||
// Replace <see cref="T:Foo.Bar"/> with the short name "Bar"
|
|||
// Replace <see langword="null"/> with "null"
|
|||
// Replace <paramref name="x"/> and <typeparamref name="T"/> with the name
|
|||
inner = XmlRefTagRegex.Replace(inner, m => |
|||
{ |
|||
var display = m.Groups["display"].Value; |
|||
// For cref values like "T:Foo.Bar.Baz", return only "Baz"
|
|||
var dot = display.LastIndexOf('.'); |
|||
return dot >= 0 ? display[(dot + 1)..] : display; |
|||
}); |
|||
|
|||
// Strip any remaining XML tags (e.g. <c>, <code>, <para>, <b>, etc.)
|
|||
inner = XmlTagRegex.Replace(inner, string.Empty); |
|||
|
|||
if (string.IsNullOrWhiteSpace(inner)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
return WhitespaceRegex.Replace(inner.Trim(), " "); |
|||
} |
|||
|
|||
private static string GetMemberNameForType(Type type) |
|||
{ |
|||
return $"T:{GetTypeFullName(type)}"; |
|||
} |
|||
|
|||
private static string GetMemberNameForMethod(MethodInfo method) |
|||
{ |
|||
var typeName = GetTypeFullName(method.DeclaringType!); |
|||
var parameters = method.GetParameters(); |
|||
if (parameters.Length == 0) |
|||
{ |
|||
return $"M:{typeName}.{method.Name}"; |
|||
} |
|||
|
|||
var paramTypes = string.Join(",", |
|||
parameters.Select(p => GetParameterTypeName(p.ParameterType))); |
|||
return $"M:{typeName}.{method.Name}({paramTypes})"; |
|||
} |
|||
|
|||
private static string GetMemberNameForProperty(PropertyInfo property) |
|||
{ |
|||
var typeName = GetTypeFullName(property.DeclaringType!); |
|||
return $"P:{typeName}.{property.Name}"; |
|||
} |
|||
|
|||
private static string GetTypeFullName(Type type) |
|||
{ |
|||
return type.FullName?.Replace('+', '.') ?? type.Name; |
|||
} |
|||
|
|||
private static string GetParameterTypeName(Type type) |
|||
{ |
|||
if (type.IsGenericType) |
|||
{ |
|||
var genericDef = type.GetGenericTypeDefinition(); |
|||
var defName = genericDef.FullName!; |
|||
defName = defName[..defName.IndexOf('`')]; |
|||
var args = string.Join(",", type.GetGenericArguments().Select(GetParameterTypeName)); |
|||
return $"{defName}{{{args}}}"; |
|||
} |
|||
|
|||
if (type.IsArray) |
|||
{ |
|||
return GetParameterTypeName(type.GetElementType()!) + "[]"; |
|||
} |
|||
|
|||
if (type.IsByRef) |
|||
{ |
|||
return GetParameterTypeName(type.GetElementType()!) + "@"; |
|||
} |
|||
|
|||
return type.FullName ?? type.Name; |
|||
} |
|||
} |
|||
@ -0,0 +1,173 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Security.Cryptography; |
|||
using System.Text; |
|||
using System.Text.Json; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.Logging; |
|||
using Microsoft.Extensions.Logging.Abstractions; |
|||
using Volo.Abp.Cli.Args; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.Cli.Commands; |
|||
|
|||
public class GenerateJwksCommand : IConsoleCommand, ITransientDependency |
|||
{ |
|||
public const string Name = "generate-jwks"; |
|||
|
|||
public ILogger<GenerateJwksCommand> Logger { get; set; } |
|||
|
|||
public GenerateJwksCommand() |
|||
{ |
|||
Logger = NullLogger<GenerateJwksCommand>.Instance; |
|||
} |
|||
|
|||
public Task ExecuteAsync(CommandLineArgs commandLineArgs) |
|||
{ |
|||
var outputDir = commandLineArgs.Options.GetOrNull("output", "o") |
|||
?? Directory.GetCurrentDirectory(); |
|||
var keySizeStr = commandLineArgs.Options.GetOrNull("key-size", "s") ?? "2048"; |
|||
var alg = commandLineArgs.Options.GetOrNull("alg") ?? "RS256"; |
|||
var kid = commandLineArgs.Options.GetOrNull("kid") ?? Guid.NewGuid().ToString("N"); |
|||
var filePrefix = commandLineArgs.Options.GetOrNull("file", "f") ?? "jwks"; |
|||
|
|||
if (!int.TryParse(keySizeStr, out var keySize) || (keySize != 2048 && keySize != 4096)) |
|||
{ |
|||
Logger.LogError("Invalid key size '{0}'. Supported values: 2048, 4096.", keySizeStr); |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
if (!IsValidAlgorithm(alg)) |
|||
{ |
|||
Logger.LogError("Invalid algorithm '{0}'. Supported values: RS256, RS384, RS512, PS256, PS384, PS512.", alg); |
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
if (!Directory.Exists(outputDir)) |
|||
{ |
|||
Directory.CreateDirectory(outputDir); |
|||
} |
|||
|
|||
Logger.LogInformation("Generating RSA {0}-bit key pair (algorithm: {1})...", keySize, alg); |
|||
|
|||
using var rsa = RSA.Create(); |
|||
rsa.KeySize = keySize; |
|||
|
|||
var jwksJson = BuildJwksJson(rsa, alg, kid); |
|||
var privateKeyPem = ExportPrivateKeyPem(rsa); |
|||
|
|||
var jwksFilePath = Path.Combine(outputDir, $"{filePrefix}.json"); |
|||
var privateKeyFilePath = Path.Combine(outputDir, $"{filePrefix}-private.pem"); |
|||
|
|||
File.WriteAllText(jwksFilePath, jwksJson, Encoding.UTF8); |
|||
File.WriteAllText(privateKeyFilePath, privateKeyPem, Encoding.UTF8); |
|||
|
|||
Logger.LogInformation(""); |
|||
Logger.LogInformation("Generated files:"); |
|||
Logger.LogInformation(" JWKS (public key) : {0}", jwksFilePath); |
|||
Logger.LogInformation(" Private key (PEM) : {0}", privateKeyFilePath); |
|||
Logger.LogInformation(""); |
|||
Logger.LogInformation("JWKS content (paste this into the ABP OpenIddict application's 'JSON Web Key Set' field):"); |
|||
Logger.LogInformation(""); |
|||
Logger.LogInformation("{0}", jwksJson); |
|||
Logger.LogInformation(""); |
|||
Logger.LogInformation("IMPORTANT: Keep the private key file safe. Never share it or commit it to source control."); |
|||
Logger.LogInformation(" The JWKS file contains only the public key and is safe to share."); |
|||
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
private static string BuildJwksJson(RSA rsa, string alg, string kid) |
|||
{ |
|||
var parameters = rsa.ExportParameters(false); |
|||
|
|||
var n = Base64UrlEncode(parameters.Modulus); |
|||
var e = Base64UrlEncode(parameters.Exponent); |
|||
|
|||
using var stream = new System.IO.MemoryStream(); |
|||
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); |
|||
|
|||
writer.WriteStartObject(); |
|||
writer.WriteStartArray("keys"); |
|||
writer.WriteStartObject(); |
|||
writer.WriteString("kty", "RSA"); |
|||
writer.WriteString("use", "sig"); |
|||
writer.WriteString("kid", kid); |
|||
writer.WriteString("alg", alg); |
|||
writer.WriteString("n", n); |
|||
writer.WriteString("e", e); |
|||
writer.WriteEndObject(); |
|||
writer.WriteEndArray(); |
|||
writer.WriteEndObject(); |
|||
writer.Flush(); |
|||
|
|||
return Encoding.UTF8.GetString(stream.ToArray()); |
|||
} |
|||
|
|||
private static string ExportPrivateKeyPem(RSA rsa) |
|||
{ |
|||
#if NET5_0_OR_GREATER
|
|||
return rsa.ExportPkcs8PrivateKeyPem(); |
|||
#elif NETSTANDARD2_0
|
|||
// RSA.ExportPkcs8PrivateKey() was introduced in .NET Standard 2.1.
|
|||
// The ABP CLI always runs on .NET 5+, so this path is never reached at runtime.
|
|||
throw new PlatformNotSupportedException("Private key export requires .NET Standard 2.1 or later."); |
|||
#else
|
|||
var privateKeyBytes = rsa.ExportPkcs8PrivateKey(); |
|||
var base64 = Convert.ToBase64String(privateKeyBytes, Base64FormattingOptions.InsertLineBreaks); |
|||
return $"-----BEGIN PRIVATE KEY-----\n{base64}\n-----END PRIVATE KEY-----"; |
|||
#endif
|
|||
} |
|||
|
|||
private static string Base64UrlEncode(byte[] input) |
|||
{ |
|||
return Convert.ToBase64String(input) |
|||
.TrimEnd('=') |
|||
.Replace('+', '-') |
|||
.Replace('/', '_'); |
|||
} |
|||
|
|||
private static bool IsValidAlgorithm(string alg) |
|||
{ |
|||
return alg == "RS256" || alg == "RS384" || alg == "RS512" || |
|||
alg == "PS256" || alg == "PS384" || alg == "PS512"; |
|||
} |
|||
|
|||
public string GetUsageInfo() |
|||
{ |
|||
var sb = new StringBuilder(); |
|||
|
|||
sb.AppendLine(""); |
|||
sb.AppendLine("Usage:"); |
|||
sb.AppendLine(" abp generate-jwks [options]"); |
|||
sb.AppendLine(""); |
|||
sb.AppendLine("Options:"); |
|||
sb.AppendLine(" -o|--output <dir> Output directory (default: current directory)"); |
|||
sb.AppendLine(" -s|--key-size <size> RSA key size: 2048 or 4096 (default: 2048)"); |
|||
sb.AppendLine(" --alg <alg> Algorithm: RS256, RS384, RS512, PS256, PS384, PS512 (default: RS256)"); |
|||
sb.AppendLine(" --kid <id> Key ID (kid) - auto-generated if not specified"); |
|||
sb.AppendLine(" -f|--file <prefix> Output file name prefix (default: jwks)"); |
|||
sb.AppendLine(" Generates: <prefix>.json (JWKS) and <prefix>-private.pem (private key)"); |
|||
sb.AppendLine(""); |
|||
sb.AppendLine("Examples:"); |
|||
sb.AppendLine(" abp generate-jwks"); |
|||
sb.AppendLine(" abp generate-jwks --alg RS512 --key-size 4096"); |
|||
sb.AppendLine(" abp generate-jwks -o ./keys -f myapp"); |
|||
sb.AppendLine(""); |
|||
sb.AppendLine("Description:"); |
|||
sb.AppendLine(" Generates an RSA key pair for use with OpenIddict private_key_jwt client authentication."); |
|||
sb.AppendLine(" The JWKS file (public key) should be pasted into the ABP OpenIddict application's"); |
|||
sb.AppendLine(" 'JSON Web Key Set' field in the management UI."); |
|||
sb.AppendLine(" The private key PEM file should be kept secure and used by the client application"); |
|||
sb.AppendLine(" to sign JWT assertions when authenticating to the token endpoint."); |
|||
sb.AppendLine(""); |
|||
sb.AppendLine("See the documentation for more info: https://abp.io/docs/latest/cli"); |
|||
|
|||
return sb.ToString(); |
|||
} |
|||
|
|||
public static string GetShortDescription() |
|||
{ |
|||
return "Generates an RSA key pair (JWKS + private key) for OpenIddict private_key_jwt authentication."; |
|||
} |
|||
} |
|||
@ -1,6 +1,8 @@ |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.Http.Modeling; |
|||
|
|||
public interface IApiDescriptionModelProvider |
|||
{ |
|||
ApplicationApiDescriptionModel CreateApiModel(ApplicationApiDescriptionModelRequestDto input); |
|||
Task<ApplicationApiDescriptionModel> CreateApiModelAsync(ApplicationApiDescriptionModelRequestDto input); |
|||
} |
|||
|
|||