Skip to content

Commit 5e1323e

Browse files
๐Ÿ”’๏ธ Enhance security of API keys ๐Ÿ—ƒ๏ธ (#7085)
Co-authored-by: Copilot <[email protected]>
1 parent d0f485b commit 5e1323e

File tree

24 files changed

+370
-290
lines changed

24 files changed

+370
-290
lines changed

โ€Žapi/specs/web-server/_auth_api_keys.pyโ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from models_library.generics import Envelope
1010
from models_library.rest_error import EnvelopedError
1111
from simcore_service_webserver._meta import API_VTAG
12-
from simcore_service_webserver.api_keys._controller_rest import ApiKeysPathParams
13-
from simcore_service_webserver.api_keys._controller_rest_exceptions import (
12+
from simcore_service_webserver.api_keys._controller.rest import ApiKeysPathParams
13+
from simcore_service_webserver.api_keys._controller.rest_exceptions import (
1414
_TO_HTTP_ERROR_MAP,
1515
)
1616

โ€Žpackages/models-library/src/models_library/rpc/webserver/auth/api_keys.pyโ€Ž

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
11
import datetime as dt
2-
from typing import Annotated
2+
import hashlib
3+
import re
4+
import secrets
5+
import string
6+
from typing import Annotated, Final
37

48
from models_library.basic_types import IDStr
59
from pydantic import BaseModel, ConfigDict, Field
610

11+
_PUNCTUATION_REGEX = re.compile(
12+
pattern="[" + re.escape(string.punctuation.replace("_", "")) + "]"
13+
)
14+
15+
_KEY_LEN: Final = 10
16+
_SECRET_LEN: Final = 20
17+
18+
19+
def generate_unique_api_key(name: str, length: int = _KEY_LEN) -> str:
20+
prefix = _PUNCTUATION_REGEX.sub("_", name[:5])
21+
hashed = hashlib.sha256(name.encode()).hexdigest()
22+
return f"{prefix}_{hashed[:length]}"
23+
24+
25+
def generate_api_key_and_secret(name: str):
26+
api_key = generate_unique_api_key(name)
27+
api_secret = secrets.token_hex(_SECRET_LEN)
28+
return api_key, api_secret
29+
730

831
class ApiKeyCreate(BaseModel):
932
display_name: Annotated[str, Field(..., min_length=3)]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""hash existing api_secret data
2+
3+
Revision ID: 742123f0933a
4+
Revises: b0c988e3f348
5+
Create Date: 2025-03-13 09:39:43.895529+00:00
6+
7+
"""
8+
9+
from alembic import op
10+
11+
# revision identifiers, used by Alembic.
12+
revision = "742123f0933a"
13+
down_revision = "b0c988e3f348"
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade():
19+
op.execute(
20+
"""
21+
UPDATE api_keys
22+
SET api_secret = crypt(api_secret, gen_salt('bf', 10))
23+
"""
24+
)
25+
26+
27+
def downgrade():
28+
pass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""add index to api_key column
2+
3+
Revision ID: b0c988e3f348
4+
Revises: f65f7786cd4b
5+
Create Date: 2025-03-13 08:53:05.722855+00:00
6+
7+
"""
8+
9+
from alembic import op
10+
11+
# revision identifiers, used by Alembic.
12+
revision = "b0c988e3f348"
13+
down_revision = "f65f7786cd4b"
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade():
19+
op.create_index(op.f("ix_api_keys_api_key"), "api_keys", ["api_key"], unique=False)
20+
21+
22+
def downgrade():
23+
op.drop_index(op.f("ix_api_keys_api_key"), table_name="api_keys")

โ€Žpackages/postgres-database/src/simcore_postgres_database/models/api_keys.pyโ€Ž

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
""" API keys to access public API
1+
"""API keys to access public API
22
33
44
These keys grant the client authorization to the API resources
@@ -10,6 +10,7 @@
1010
+--------+ +---------------+
1111
1212
"""
13+
1314
import sqlalchemy as sa
1415
from sqlalchemy.sql import func
1516

@@ -52,8 +53,13 @@
5253
nullable=False,
5354
doc="Identified product",
5455
),
55-
sa.Column("api_key", sa.String(), nullable=False),
56-
sa.Column("api_secret", sa.String(), nullable=False),
56+
sa.Column("api_key", sa.String(), nullable=False, index=True),
57+
sa.Column(
58+
"api_secret",
59+
sa.String(),
60+
nullable=False,
61+
doc="API key secret, hashed using blowfish algorithm",
62+
),
5763
sa.Column(
5864
"created",
5965
sa.DateTime(),

โ€Žpackages/postgres-database/tests/test_models_api_keys.pyโ€Ž

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from aiopg.sa.connection import SAConnection
1111
from aiopg.sa.result import RowProxy
1212
from pytest_simcore.helpers.faker_factories import (
13-
random_api_key,
13+
random_api_auth,
1414
random_product,
1515
random_user,
1616
)
@@ -48,7 +48,7 @@ async def test_create_and_delete_api_key(
4848
):
4949
apikey_id = await connection.scalar(
5050
api_keys.insert()
51-
.values(**random_api_key(product_name, user_id))
51+
.values(**random_api_auth(product_name, user_id))
5252
.returning(api_keys.c.id)
5353
)
5454

@@ -66,7 +66,7 @@ async def session_auth(
6666
# uses to authenticate a session.
6767
result = await connection.execute(
6868
api_keys.insert()
69-
.values(**random_api_key(product_name, user_id))
69+
.values(**random_api_auth(product_name, user_id))
7070
.returning(sa.literal_column("*"))
7171
)
7272
row = await result.fetchone()

โ€Žpackages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.pyโ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ def random_payment_autorecharge(
405405
return data
406406

407407

408-
def random_api_key(
408+
def random_api_auth(
409409
product_name: str, user_id: int, fake: Faker = DEFAULT_FAKER, **overrides
410410
) -> dict[str, Any]:
411411
from simcore_postgres_database.models.api_keys import api_keys

โ€Žpackages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.pyโ€Ž

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from models_library.basic_types import IDStr
55
from models_library.rabbitmq_basic_types import RPCMethodName
66
from models_library.rpc.webserver.auth.api_keys import ApiKeyCreate, ApiKeyGet
7+
from models_library.users import UserID
78
from pydantic import TypeAdapter
89
from servicelib.logging_utils import log_decorator
910
from servicelib.rabbitmq import RabbitMQRPCClient
@@ -15,7 +16,7 @@
1516
async def create_api_key(
1617
rabbitmq_rpc_client: RabbitMQRPCClient,
1718
*,
18-
user_id: str,
19+
user_id: UserID,
1920
product_name: str,
2021
api_key: ApiKeyCreate,
2122
) -> ApiKeyGet:
@@ -24,7 +25,8 @@ async def create_api_key(
2425
TypeAdapter(RPCMethodName).validate_python("create_api_key"),
2526
user_id=user_id,
2627
product_name=product_name,
27-
api_key=api_key,
28+
display_name=api_key.display_name,
29+
expiration=api_key.expiration,
2830
)
2931
assert isinstance(result, ApiKeyGet) # nosec
3032
return result
@@ -34,7 +36,7 @@ async def create_api_key(
3436
async def get_api_key(
3537
rabbitmq_rpc_client: RabbitMQRPCClient,
3638
*,
37-
user_id: str,
39+
user_id: UserID,
3840
product_name: str,
3941
api_key_id: IDStr,
4042
) -> ApiKeyGet:
@@ -49,18 +51,19 @@ async def get_api_key(
4951
return result
5052

5153

52-
async def delete_api_key(
54+
@log_decorator(_logger, level=logging.DEBUG)
55+
async def delete_api_key_by_key(
5356
rabbitmq_rpc_client: RabbitMQRPCClient,
5457
*,
55-
user_id: str,
58+
user_id: UserID,
5659
product_name: str,
57-
api_key_id: IDStr,
60+
api_key: str,
5861
) -> None:
5962
result = await rabbitmq_rpc_client.request(
6063
WEBSERVER_RPC_NAMESPACE,
61-
TypeAdapter(RPCMethodName).validate_python("delete_api_key"),
64+
TypeAdapter(RPCMethodName).validate_python("delete_api_key_by_key"),
6265
user_id=user_id,
6366
product_name=product_name,
64-
api_key_id=api_key_id,
67+
api_key=api_key,
6568
)
6669
assert result is None # nosec

โ€Žservices/api-server/src/simcore_service_api_server/db/repositories/api_keys.pyโ€Ž

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ async def get_user(
2222
self, api_key: str, api_secret: str
2323
) -> UserAndProductTuple | None:
2424
stmt = sa.select(tbl.api_keys.c.user_id, tbl.api_keys.c.product_name).where(
25-
(tbl.api_keys.c.api_key == api_key)
26-
& (tbl.api_keys.c.api_secret == api_secret),
25+
(tbl.api_keys.c.api_key == api_key) # NOTE: keep order, api_key is indexed
26+
& (
27+
tbl.api_keys.c.api_secret
28+
== sa.func.crypt(api_secret, tbl.api_keys.c.api_secret)
29+
)
2730
)
2831
result: UserAndProductTuple | None = None
2932
try:

โ€Žservices/api-server/tests/unit/_with_db/conftest.pyโ€Ž

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from pytest_mock import MockerFixture
2626
from pytest_simcore.helpers import postgres_tools
2727
from pytest_simcore.helpers.faker_factories import (
28-
random_api_key,
28+
random_api_auth,
2929
random_product,
3030
random_user,
3131
)
@@ -255,31 +255,39 @@ async def create_fake_api_keys(
255255
create_product_names: Callable[[PositiveInt], AsyncGenerator[str, None]],
256256
) -> AsyncGenerator[Callable[[PositiveInt], AsyncGenerator[ApiKeyInDB, None]], None]:
257257
async def _generate_fake_api_key(n: PositiveInt):
258-
users = create_user_ids(n)
259-
products = create_product_names(n)
258+
users, products = create_user_ids(n), create_product_names(n)
259+
excluded_column = "api_secret"
260+
returning_cols = [col for col in api_keys.c if col.name != excluded_column]
261+
260262
for _ in range(n):
261263
product = await anext(products)
262264
user = await anext(users)
265+
api_auth = random_api_auth(product, user)
266+
plain_api_secret = api_auth.pop("api_secret")
263267
result = await connection.execute(
264268
api_keys.insert()
265-
.values(**random_api_key(product, user))
266-
.returning(sa.literal_column("*"))
269+
.values(
270+
api_secret=sa.func.crypt(plain_api_secret, sa.func.gen_salt("bf", 10)),
271+
**api_auth,
272+
)
273+
.returning(*returning_cols)
267274
)
268275
row = await result.fetchone()
269276
assert row
270277
_generate_fake_api_key.row_ids.append(row.id)
271-
yield ApiKeyInDB.model_validate(row)
278+
yield ApiKeyInDB.model_validate({"api_secret": plain_api_secret, **row})
272279

273280
_generate_fake_api_key.row_ids = []
274281
yield _generate_fake_api_key
275282

276-
for row_id in _generate_fake_api_key.row_ids:
277-
await connection.execute(api_keys.delete().where(api_keys.c.id == row_id))
283+
await connection.execute(
284+
api_keys.delete().where(api_keys.c.id.in_(_generate_fake_api_key.row_ids))
285+
)
278286

279287

280288
@pytest.fixture
281289
async def auth(
282-
create_fake_api_keys: Callable[[PositiveInt], AsyncGenerator[ApiKeyInDB, None]]
290+
create_fake_api_keys: Callable[[PositiveInt], AsyncGenerator[ApiKeyInDB, None]],
283291
) -> httpx.BasicAuth:
284292
"""overrides auth and uses access to real repositories instead of mocks"""
285293
async for key in create_fake_api_keys(1):

0 commit comments

Comments
ย (0)