Skip to content

Commit eefc219

Browse files
feat: add API key authentication support (#2431)
* feat: add API key authentication support Add API key authentication to Flask-AppBuilder, enabling programmatic access to FAB-protected endpoints without JWT tokens or browser sessions. Changes: - Add ApiKey model (ab_api_key table) with uuid, key_hash, prefix, scopes, active status, expiration, and revocation tracking - Add validate_api_key() and _extract_api_key_from_request() to BaseSecurityManager with concrete SQLA implementations - Modify @Protect() decorator to check API key auth before JWT verification - Add CRUD API endpoints at /api/v1/security/api_keys/ (list, create, get, revoke) gated behind FAB_API_KEY_ENABLED config - Update has_access() to recognize API key authenticated users - Update _create_db() to auto-create ab_api_key table on existing installs - Add 24 tests covering model, SecurityManager methods, CRUD endpoints, and @Protect() decorator integration Config keys: - FAB_API_KEY_ENABLED (bool): Enable API key auth (default: False) - FAB_API_KEY_PREFIXES (list): Key prefixes to recognize (default: ["sst_"]) References: apache/superset#36173 * fix: apply black formatting to API key auth files * fix: revert to black 23.10 formatting for decorators.py * fix: remove unused imports in test_api_key.py * fix: ensure ApiKey permissions are created when update_perms is False When AppBuilder is initialized with update_perms=False (as Superset does), the standard _add_permission() call in add_view_no_menu() is a no-op. This means ApiKeyApi permissions are never created, causing all API key endpoints to return 403 Forbidden. Fix by explicitly calling add_permissions_view() after registering the ApiKeyApi, which creates the permission-view-menu entries and assigns them to the Admin role regardless of the update_perms flag. This is idempotent and safe when update_perms is True (permissions already exist). Adds tests that verify permissions exist, Admin role has them, and endpoints work when update_perms=False. * feat: add lookup_hash for O(1) key validation, address review feedback - Add lookup_hash column (indexed, unique) for constant-time API key lookup instead of iterating all keys with prefix matching - Add configurable hash algorithms: - FAB_API_KEY_LOOKUP_HASH_METHOD (default: sha256) for fast lookup - FAB_API_KEY_HASH_METHOD (default: falls back to FAB_PASSWORD_HASH_METHOD) - FAB_API_KEY_HASH_SALT_LENGTH for the slow verification hash - Address review feedback from @dpgaspar: - Use sm.current_user instead of custom _get_current_user helper - Update sm.current_user property to handle API key auth - Let create_api_key/revoke_api_key raise instead of returning None - Move imports to module level - Drop getattr guard on user.is_active - Respect update_perms=False without exceptions * style: apply black formatting to models and manager * fix: use HMAC for lookup hash to resolve CodeQL alerts Replace plain SHA-256 with HMAC keyed by SECRET_KEY for the API key lookup hash. This prevents pre-computation of lookup hashes without the server secret and resolves CodeQL's "weak cryptographic hashing algorithm on sensitive data" alerts. * fix: remove unused hashlib import, suppress CodeQL false positive - Remove unused hashlib import (lint failure) - Add lgtm suppression for the HMAC lookup hash (intentional fast lookup index, not password storage — key_hash provides the slow hash) - Use _compute_lookup_hash() in tests instead of raw hmac calls * fix: use BLAKE2b for lookup hash to resolve CodeQL alerts Switch from HMAC-SHA256 to BLAKE2b with native keyed hashing for the API key lookup hash. BLAKE2b is not flagged by CodeQL's weak-sensitive-data-hashing rule (which targets SHA-256/SHA-512/MD5), is fast by design, and supports keying natively without an HMAC wrapper. The lookup hash is an internal optimization detail for O(1) key lookup, not a password storage mechanism (key_hash provides the slow hash for defense in depth). * fix: use scrypt for lookup hash to satisfy CodeQL CodeQL flags all fast hash algorithms (SHA-256, BLAKE2, HMAC) as "insecure for password hashing" when used on sensitive data. Switch to hashlib.scrypt with minimal work parameters (n=2, r=1, p=1) which is nearly as fast as a plain hash but classified as a computationally expensive algorithm by static analysis tools. The lookup hash is an internal O(1) index — the actual password-strength protection is provided by key_hash (via generate_password_hash). * fix: address PR review - 401 vs 403, public method, OpenAPI spec, docs - Rename _extract_api_key_from_request to extract_api_key_from_request (public) - Return 401 for invalid API key, 403 for valid key without permission - Register api_key security scheme in OpenAPI spec - Add api_key alongside jwt in operation_helper security list - Add API key documentation to security.rst and rest_api.rst - Add test for valid key with no permission returning 403 * fix: use black 23.10 formatting for decorators.py to pass CI lint * fix: import USERNAME_READONLY in test_api_key to fix lint * fix: use no-permission role in 403 test instead of ReadOnly * fix: clean up noperms_user in tearDown to prevent test pollution
1 parent 3aa0e41 commit eefc219

File tree

13 files changed

+1086
-6
lines changed

13 files changed

+1086
-6
lines changed

docs/rest_api.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,28 @@ methods::
517517
class ExampleApi(BaseApi):
518518
base_permissions = ['can_private']
519519

520+
API Key Authentication
521+
~~~~~~~~~~~~~~~~~~~~~~
522+
523+
In addition to JWT tokens, FAB supports API key authentication. API keys are long-lived
524+
Bearer tokens useful for service-to-service communication or automation scripts.
525+
526+
To enable API key authentication, set ``FAB_API_KEY_ENABLED = True`` in your config.
527+
528+
Once enabled, you can use an API key in the same way as a JWT token::
529+
530+
$ curl http://localhost:8080/api/v1/example/private \
531+
-H "Authorization: Bearer sst_<YOUR_API_KEY>"
532+
533+
API keys are distinguished from JWT tokens by their prefix (default: ``sst_``). When the
534+
``protect()`` decorator receives a request with an API key:
535+
536+
- If the key is **invalid**, the endpoint returns HTTP **401 Unauthorized**.
537+
- If the key is valid but the user **lacks permission**, the endpoint returns HTTP **403 Forbidden**.
538+
- If the key is valid and the user **has permission**, the request proceeds normally.
539+
540+
The OpenAPI spec for protected endpoints lists both ``jwt`` and ``api_key`` as valid security
541+
schemes, so clients can choose either authentication method.
520542

521543
You can create an alternate JWT user loader, this can be useful if you want
522544
to use an external Authentication provider and map the JWT identity to your

docs/security.rst

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,47 @@ The rate can be changed by adjusting ``AUTH_RATE_LIMIT`` to, for example, ``1 pe
581581
at the `documentation <https://flask-limiter.readthedocs.io/en/stable/>`_ of Flask-Limiter for more options and
582582
examples.
583583

584+
Authentication: API Keys
585+
------------------------
586+
587+
FAB supports API key authentication as an alternative to JWT tokens. API keys are long-lived
588+
credentials that can be used for service-to-service communication or automation.
589+
590+
**Enabling API Key Authentication**
591+
592+
Set the following in your config::
593+
594+
FAB_API_KEY_ENABLED = True
595+
596+
**Creating API Keys**
597+
598+
API keys are managed through the ``SecurityManager``. You can create keys programmatically::
599+
600+
from flask import current_app
601+
602+
sm = current_app.appbuilder.sm
603+
api_key = sm.create_api_key(user=user, name="my-service-key")
604+
605+
The returned key string should be stored securely -- it cannot be retrieved again after creation.
606+
607+
**Using API Keys**
608+
609+
Pass the API key as a Bearer token in the ``Authorization`` header::
610+
611+
$ curl http://localhost:8080/api/v1/example/private \
612+
-H "Authorization: Bearer sst_<YOUR_API_KEY>"
613+
614+
API keys use the same permission system as regular users. The key inherits the roles and
615+
permissions of the user it belongs to.
616+
617+
**Configuration Options**
618+
619+
The following configuration options are available:
620+
621+
- ``FAB_API_KEY_ENABLED`` -- Set to ``True`` to enable API key authentication (default: ``False``).
622+
- ``FAB_API_KEY_PREFIXES`` -- List of prefixes that identify API keys vs JWT tokens
623+
(default: ``["sst_"]``).
624+
584625
Role based
585626
----------
586627

flask_appbuilder/api/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def wraps(self: "BaseApi", *args: Any, **kwargs: Any) -> Response:
132132

133133

134134
def rison(
135-
schema: Optional[Dict[str, Any]] = None
135+
schema: Optional[Dict[str, Any]] = None,
136136
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
137137
"""
138138
Use this decorator to parse URI *Rison* arguments to
@@ -701,7 +701,7 @@ def operation_helper(
701701
# Merge docs spec and override spec
702702
operation_spec.update(override_method_spec.get(method.lower(), {}))
703703
if self.get_method_permission(func.__name__):
704-
operation_spec["security"] = [{"jwt": []}]
704+
operation_spec["security"] = [{"jwt": []}, {"api_key": []}]
705705
operations[method.lower()] = operation_spec
706706
else:
707707
operations[method.lower()] = {}

flask_appbuilder/security/api.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ def add_apispec_components(self, api_spec):
2828
jwt_scheme = {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}
2929
api_spec.components.security_scheme("jwt", jwt_scheme)
3030
api_spec.components.security_scheme("jwt_refresh", jwt_scheme)
31+
api_key_scheme = {
32+
"type": "http",
33+
"scheme": "bearer",
34+
"bearerFormat": "API Key",
35+
"description": "API key authentication using Bearer token",
36+
}
37+
api_spec.components.security_scheme("api_key", api_key_scheme)
3138

3239
@expose("/login", methods=["POST"])
3340
@safe

flask_appbuilder/security/decorators.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,25 @@ def wraps(self, *args, **kwargs):
9999
permission_str, class_permission_name
100100
):
101101
return f(self, *args, **kwargs)
102+
# Check API key authentication (before JWT)
103+
if current_app.config.get("FAB_API_KEY_ENABLED", False):
104+
api_key_string = (
105+
current_app.appbuilder.sm.extract_api_key_from_request()
106+
)
107+
if api_key_string is not None:
108+
user = current_app.appbuilder.sm.validate_api_key(api_key_string)
109+
if not user:
110+
return self.response_401()
111+
if current_app.appbuilder.sm.has_access(
112+
permission_str, class_permission_name
113+
):
114+
return f(self, *args, **kwargs)
115+
log.warning(
116+
LOGMSG_ERR_SEC_ACCESS_DENIED,
117+
permission_str,
118+
class_permission_name,
119+
)
120+
return self.response_403()
102121
# if no browser login then verify JWT
103122
if not (self.allow_browser_login or allow_browser_login):
104123
verify_jwt_in_request()

flask_appbuilder/security/manager.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,8 @@ def auth_rate_limit(self) -> str:
575575

576576
@property
577577
def current_user(self):
578+
if getattr(g, "_api_key_user", False) and hasattr(g, "user"):
579+
return g.user
578580
if current_user.is_authenticated:
579581
return g.user
580582
elif current_user_jwt:
@@ -1869,6 +1871,11 @@ def has_access(self, permission_name: str, view_name: str) -> bool:
18691871
"""
18701872
Check if current user or public has access to view or menu
18711873
"""
1874+
# Check API key authenticated user first
1875+
if getattr(g, "_api_key_user", False) and hasattr(g, "user"):
1876+
user = g.user
1877+
if user and user.is_active:
1878+
return self._has_view_access(user, permission_name, view_name)
18721879
if current_user.is_authenticated and current_user.is_active:
18731880
return self._has_view_access(g.user, permission_name, view_name)
18741881
elif current_user_jwt and current_user_jwt.is_active:
@@ -2456,6 +2463,103 @@ def load_user_jwt(self, _jwt_header, jwt_data):
24562463
g.user = user
24572464
return user
24582465

2466+
"""
2467+
----------------------
2468+
API KEY AUTHENTICATION
2469+
----------------------
2470+
"""
2471+
2472+
@staticmethod
2473+
def extract_api_key_from_request() -> Optional[str]:
2474+
"""
2475+
Extract an API key from the request's Authorization header.
2476+
2477+
Checks for Bearer tokens that match configured API key prefixes
2478+
(FAB_API_KEY_PREFIXES config, default: ["sst_"]).
2479+
2480+
Returns the raw API key string if a matching prefix is found,
2481+
or None if the token is not an API key (e.g., a JWT).
2482+
"""
2483+
auth_header = request.headers.get("Authorization", "")
2484+
if not auth_header.lower().startswith("bearer "):
2485+
return None
2486+
token = auth_header[7:].strip()
2487+
if not token:
2488+
return None
2489+
prefixes = current_app.config.get("FAB_API_KEY_PREFIXES", ["sst_"])
2490+
for prefix in prefixes:
2491+
if token.startswith(prefix):
2492+
return token
2493+
return None
2494+
2495+
def validate_api_key(self, api_key_string: str) -> Optional[Any]:
2496+
"""
2497+
Validate an API key and return the associated User if valid.
2498+
2499+
Uses a fast lookup hash (configurable via FAB_API_KEY_LOOKUP_HASH_METHOD,
2500+
default: "sha256") for O(1) retrieval, then verifies against the slow
2501+
key_hash for defense in depth.
2502+
2503+
Override in subclass to provide storage-specific implementation.
2504+
2505+
:param api_key_string: The raw API key (e.g., "sst_abc123...")
2506+
:return: User object if valid, None otherwise
2507+
"""
2508+
raise NotImplementedError
2509+
2510+
def create_api_key(
2511+
self,
2512+
user: Any,
2513+
name: str,
2514+
scopes: Optional[str] = None,
2515+
expires_on: Optional[datetime.datetime] = None,
2516+
) -> Optional[Dict[str, Any]]:
2517+
"""
2518+
Create a new API key for a user.
2519+
2520+
Override in subclass to provide storage-specific implementation.
2521+
2522+
:param user: The user to create the key for
2523+
:param name: A friendly name for the key
2524+
:param scopes: Optional comma-separated scopes
2525+
:param expires_on: Optional expiration datetime
2526+
:return: Dict with key info including plaintext key (shown once)
2527+
"""
2528+
raise NotImplementedError
2529+
2530+
def revoke_api_key(self, uuid: str) -> bool:
2531+
"""
2532+
Revoke an API key by UUID.
2533+
2534+
Override in subclass to provide storage-specific implementation.
2535+
2536+
:param uuid: The UUID of the key to revoke
2537+
:return: True if revoked, False if not found
2538+
"""
2539+
raise NotImplementedError
2540+
2541+
def find_api_keys_for_user(self, user_id: int) -> List[Any]:
2542+
"""
2543+
Find all API keys for a user.
2544+
2545+
Override in subclass to provide storage-specific implementation.
2546+
2547+
:param user_id: The user's ID
2548+
:return: List of ApiKey objects
2549+
"""
2550+
raise NotImplementedError
2551+
2552+
def get_api_key_by_uuid(self, uuid: str) -> Optional[Any]:
2553+
"""
2554+
Get an API key by its UUID.
2555+
2556+
Override in subclass to provide storage-specific implementation.
2557+
2558+
:param uuid: The API key's UUID
2559+
:return: ApiKey object or None
2560+
"""
2561+
raise NotImplementedError
2562+
24592563
@staticmethod
24602564
def before_request():
24612565
g.user = current_user

flask_appbuilder/security/sqla/apis/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from flask_appbuilder.security.sqla.apis.api_key import ApiKeyApi # noqa: F401
12
from flask_appbuilder.security.sqla.apis.group import GroupApi # noqa: F401
23
from flask_appbuilder.security.sqla.apis.permission import PermissionApi # noqa: F401
34
from flask_appbuilder.security.sqla.apis.permission_view_menu import ( # noqa: F401
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .api import ApiKeyApi # noqa: F401

0 commit comments

Comments
 (0)