Skip to content

Commit b9edefe

Browse files
committed
fix(cached): cached service update (delete or update method) clear cache key
1 parent 03693a8 commit b9edefe

File tree

2 files changed

+86
-79
lines changed

2 files changed

+86
-79
lines changed

src/fastapi_api_key/services/cached.py

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from fastapi_api_key import ApiKeyService
1515
from fastapi_api_key.domain.base import D
16-
from fastapi_api_key.domain.errors import KeyNotProvided
16+
from fastapi_api_key.domain.errors import KeyNotProvided, InvalidKey, InvalidScopes, KeyNotFound
1717
from fastapi_api_key.hasher.base import ApiKeyHasher
1818
from fastapi_api_key.repositories.base import AbstractApiKeyRepository
1919
from fastapi_api_key.services.base import DEFAULT_SEPARATOR
@@ -47,26 +47,87 @@ def __init__(
4747
def _get_cache_key(self, key_id: str) -> str:
4848
return f"{self.cache_prefix}:{key_id}"
4949

50-
@staticmethod
51-
def _hash_api_key(api_key: str) -> str:
50+
def _hash_api_key(self, entity: D) -> str:
5251
"""Hash the API key to use as cache key (don't store raw keys) with SHA256 (faster that Bcrypt)."""
53-
buffer = api_key.encode()
52+
# buffer = api_key.encode()
53+
# _, key_prefix, _ = api_key.partition(DEFAULT_SEPARATOR)
54+
# buffer = key_prefix.encode()
55+
# return hashlib.sha256(buffer).hexdigest()
56+
key_id, key_hash = entity.key_id, entity.key_hash
57+
buffer = f"{key_id}{key_hash}".encode()
5458
return hashlib.sha256(buffer).hexdigest()
5559

60+
async def _initialize_cache(self, entity: D) -> D:
61+
"""Helper to initialize cache for an entity, to use after any update of the entity."""
62+
hash_api_key = self._hash_api_key(entity)
63+
await self.cache.delete(hash_api_key)
64+
return entity
65+
66+
async def update(self, entity: D) -> D:
67+
# Delete cache entry on update (useful when changing scopes or disabling)
68+
entity = await super().update(entity)
69+
return await self._initialize_cache(entity)
70+
71+
async def delete_by_id(self, id_: str) -> bool:
72+
result = await self._repo.get_by_id(id_)
73+
74+
if result is None:
75+
raise KeyNotFound(f"API key with ID '{id_}' not found")
76+
77+
# Delete cache entry on delete
78+
# Todo: Found more optimized way to do this (delete_by_id return directly entity, or delete method)
79+
await super().delete_by_id(id_)
80+
await self._initialize_cache(result)
81+
return True
82+
5683
async def verify_key(self, api_key: Optional[str] = None, required_scopes: Optional[List[str]] = None) -> D:
84+
required_scopes = required_scopes or []
85+
5786
if api_key is None:
5887
raise KeyNotProvided("Api key must be provided (not given)")
5988

60-
hash_api_key = self._hash_api_key(api_key)
89+
if api_key.strip() == "":
90+
raise KeyNotProvided("Api key must be provided (empty)")
91+
92+
# Get the key_id part from the plain key
93+
global_prefix, key_id, key_secret = self._get_parts(api_key)
94+
95+
# Global key_id "ak" for "api key"
96+
if global_prefix != self.global_prefix:
97+
raise InvalidKey("Api key is invalid (wrong global prefix)")
98+
99+
# Search entity by a key_id (can't brute force hashes)
100+
entity = await self.get_by_key_id(key_id)
101+
102+
hash_api_key = self._hash_api_key(entity)
61103
cached_entity = await self.cache.get(hash_api_key)
62104

63105
if cached_entity:
64106
return cached_entity
65107

66-
entity = await super().verify_key(
67-
api_key=api_key,
68-
required_scopes=required_scopes,
69-
)
108+
# Check if the entity can be used for authentication
109+
# and refresh last_used_at if verified
110+
entity.ensure_can_authenticate()
70111

71-
await self.cache.set(hash_api_key, entity)
72-
return entity
112+
key_hash = entity.key_hash
113+
114+
if not key_secret:
115+
raise InvalidKey("API key is invalid (empty secret)")
116+
117+
if not self._hasher.verify(key_hash, key_secret):
118+
raise InvalidKey("API key is invalid (hash mismatch)")
119+
120+
if required_scopes:
121+
missing_scopes = [scope for scope in required_scopes if scope not in entity.scopes]
122+
missing_scopes_str = ", ".join(missing_scopes)
123+
if missing_scopes:
124+
raise InvalidScopes(f"API key is missing required scopes: {missing_scopes_str}")
125+
126+
entity.touch()
127+
updated = await self._repo.update(entity)
128+
129+
if updated is None:
130+
raise KeyNotFound(f"API key with ID '{entity.id_}' not found during touch update")
131+
132+
await self.cache.set(hash_api_key, updated)
133+
return updated

tests/units/test_svc.py

Lines changed: 14 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -474,22 +474,13 @@ async def test_verify_key_hashes_key_and_writes_to_cache_on_miss(
474474
2) super().verify_key -> returns a fake entity
475475
3) Cache.set is called with the SHA256 hash key and the returned entity
476476
"""
477-
# Arrange
478-
api_key = "super-secret-key"
479-
expected_hash = hashlib.sha256(api_key.encode()).hexdigest()
480-
fake_entity = {"id": "abc123"}
481-
482477
# Mock cache with async get/set
483-
cache = MagicMock()
478+
cache = AsyncMock()
484479
cache.get = AsyncMock(return_value=None)
485480
cache.set = AsyncMock()
486481

487-
# Patch the parent class verify_key to avoid touching real repo logic
488-
super_verify_mock = AsyncMock(return_value=fake_entity)
489-
monkeypatch.setattr(ApiKeyService, "verify_key", super_verify_mock)
490-
491482
# Build service
492-
repo_mock = MagicMock() # not used directly since we patched super().verify_key
483+
repo_mock = InMemoryApiKeyRepository()
493484
service = CachedApiKeyService(
494485
repo=repo_mock,
495486
cache=cache,
@@ -500,21 +491,15 @@ async def test_verify_key_hashes_key_and_writes_to_cache_on_miss(
500491
global_prefix="ak",
501492
)
502493

503-
# Act
504-
result = await service.verify_key(api_key)
494+
entity = ApiKey(name="test")
495+
entity, api_key = await service.create(entity)
505496

506-
# Assert
507-
assert result == fake_entity
497+
expected_hash = hashlib.sha256(f"{entity.key_id}{entity.key_hash}".encode()).hexdigest()
498+
await service.verify_key(api_key)
508499

509500
# cache.get must be called with the hashed key only (never the raw key)
510501
cache.get.assert_awaited_once_with(expected_hash)
511-
assert not any(call_args[0][0] == api_key for call_args in cache.get.await_args_list)
512-
513-
# super().verify_key must be called once on miss
514-
super_verify_mock.assert_awaited_once_with(api_key=api_key, required_scopes=None)
515-
516-
# cache.set must store under the hashed key
517-
cache.set.assert_awaited_once_with(expected_hash, fake_entity)
502+
cache.set.assert_awaited_once_with(expected_hash, entity)
518503

519504

520505
@pytest.mark.asyncio
@@ -523,71 +508,32 @@ async def test_verify_key_returns_cached_when_present(
523508
fixed_salt_hasher: ApiKeyHasher,
524509
):
525510
"""If the cache already contains the entity, return it and do NOT call the repo/super."""
526-
# Arrange
527-
api_key = "cached-key"
528-
expected_hash = hashlib.sha256(api_key.encode()).hexdigest()
529-
cached_entity = {"id": "in-cache"}
511+
entity = ApiKey(name="test")
530512

531-
cache = MagicMock()
532-
cache.get = AsyncMock(return_value=cached_entity)
513+
cache = AsyncMock()
514+
cache.get = AsyncMock(return_value=entity)
533515
cache.set = AsyncMock()
534516

535517
# Even if patched, it must NOT be called in this scenario
536518
super_verify_mock = AsyncMock()
537519
monkeypatch.setattr(ApiKeyService, "verify_key", super_verify_mock)
538520

539-
repo_mock = MagicMock()
521+
repo_mock = InMemoryApiKeyRepository()
540522
service = CachedApiKeyService(
541523
repo=repo_mock,
542524
cache=cache,
543525
hasher=fixed_salt_hasher,
544526
)
545527

546-
# Act
547-
result = await service.verify_key(api_key)
528+
entity, api_key = await service.create(entity)
529+
expected_hash = hashlib.sha256(f"{entity.key_id}{entity.key_hash}".encode()).hexdigest()
548530

549-
# Assert
550-
assert result == cached_entity
531+
await service.verify_key(api_key)
551532
cache.get.assert_awaited_once_with(expected_hash)
552533
super_verify_mock.assert_not_awaited()
553534
cache.set.assert_not_awaited()
554535

555536

556-
@pytest.mark.asyncio
557-
async def test_verify_key_calls_super_on_cache_miss(
558-
monkeypatch: pytest.MonkeyPatch,
559-
fixed_salt_hasher: ApiKeyHasher,
560-
):
561-
"""On cache miss, the service must call super().verify_key and then populate the cache."""
562-
# Arrange
563-
api_key = "needs-lookup"
564-
expected_hash = hashlib.sha256(api_key.encode()).hexdigest()
565-
entity_from_repo = {"id": "from-repo"}
566-
567-
cache = MagicMock()
568-
cache.get = AsyncMock(return_value=None) # miss
569-
cache.set = AsyncMock()
570-
571-
super_verify_mock = AsyncMock(return_value=entity_from_repo)
572-
monkeypatch.setattr(ApiKeyService, "verify_key", super_verify_mock)
573-
574-
repo_mock = MagicMock()
575-
service = CachedApiKeyService(
576-
repo=repo_mock,
577-
cache=cache,
578-
hasher=fixed_salt_hasher,
579-
)
580-
581-
# Act
582-
result = await service.verify_key(api_key)
583-
584-
# Assert
585-
assert result == entity_from_repo
586-
cache.get.assert_awaited_once_with(expected_hash)
587-
super_verify_mock.assert_awaited_once_with(api_key="needs-lookup", required_scopes=None)
588-
cache.set.assert_awaited_once_with(expected_hash, entity_from_repo)
589-
590-
591537
@pytest.mark.asyncio
592538
async def test_verify_key_raises_when_missing_key(fixed_salt_hasher: ApiKeyHasher):
593539
"""If no API key is provided, a KeyNotProvided error must be raised."""

0 commit comments

Comments
 (0)