feat(auth): API key token generation/hashing utilities (Stage 1 PR1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
"""Auth primitives shared by the headless API (Stage 1).
|
||||||
|
|
||||||
|
Lives in the ``deerflow`` (harness) layer because both the persistence
|
||||||
|
hot path (``ApiKeyRepository.get_active_by_hash``) and the app-layer
|
||||||
|
mint endpoint need token generation/hashing, and the harness boundary
|
||||||
|
forbids ``deerflow`` importing ``app``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from deerflow.auth.tokens import GeneratedKey, generate_api_key, hash_api_key, split_prefix
|
||||||
|
|
||||||
|
__all__ = ["GeneratedKey", "generate_api_key", "hash_api_key", "split_prefix"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""API key generation, hashing, and prefix extraction (Stage 1 PR1).
|
||||||
|
|
||||||
|
Format is irreversible once business systems integrate (spec D5):
|
||||||
|
``dfk_live_<24>`` / ``dfk_test_<24>``. The public ``key_prefix`` is the
|
||||||
|
first 16 chars (``dfk_live_`` + 7 random) and is stored UNIQUE for audit
|
||||||
|
logging; the DB only ever stores ``sha256(plaintext)`` hex, never the
|
||||||
|
plaintext.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
_PREFIX_LEN = 16
|
||||||
|
# token_urlsafe(18) yields ceil(18 * 4 / 3) = 24 url-safe chars.
|
||||||
|
_RANDOM_BYTES = 18
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GeneratedKey:
|
||||||
|
"""A freshly minted key. ``plaintext`` is returned to the caller
|
||||||
|
exactly once; only ``prefix`` + ``key_hash`` are persisted."""
|
||||||
|
|
||||||
|
plaintext: str
|
||||||
|
prefix: str
|
||||||
|
key_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
def hash_api_key(plaintext: str) -> str:
|
||||||
|
"""Return the sha-256 hex digest of a plaintext token."""
|
||||||
|
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def split_prefix(plaintext: str) -> str:
|
||||||
|
"""Return the public, loggable prefix (first 16 chars) of a token."""
|
||||||
|
return plaintext[:_PREFIX_LEN]
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key(env: Literal["live", "test"]) -> GeneratedKey:
|
||||||
|
"""Generate a new API key for the given environment.
|
||||||
|
|
||||||
|
Raises ``ValueError`` for any env other than ``"live"`` / ``"test"``.
|
||||||
|
"""
|
||||||
|
if env not in ("live", "test"):
|
||||||
|
raise ValueError(f"env must be 'live' or 'test', got {env!r}")
|
||||||
|
random_part = secrets.token_urlsafe(_RANDOM_BYTES)
|
||||||
|
plaintext = f"dfk_{env}_{random_part}"
|
||||||
|
return GeneratedKey(plaintext=plaintext, prefix=split_prefix(plaintext), key_hash=hash_api_key(plaintext))
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tests for deerflow.auth.tokens (Stage 1 PR1).
|
||||||
|
|
||||||
|
API key 格式 / 哈希 / prefix 截取。格式锁定 dfk_{live,test}_<24>,
|
||||||
|
prefix = 前 16 字符(含 dfk_live_),sha256 hex 存储(spec D5)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.auth.tokens import GeneratedKey, generate_api_key, hash_api_key, split_prefix
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_live_key_shape():
|
||||||
|
key = generate_api_key("live")
|
||||||
|
assert isinstance(key, GeneratedKey)
|
||||||
|
assert key.plaintext.startswith("dfk_live_")
|
||||||
|
# dfk_live_ (9) + token_urlsafe(18) (24) = 33 chars
|
||||||
|
assert len(key.plaintext) == 33
|
||||||
|
assert key.prefix == key.plaintext[:16]
|
||||||
|
assert len(key.prefix) == 16
|
||||||
|
assert key.key_hash == hashlib.sha256(key.plaintext.encode("utf-8")).hexdigest()
|
||||||
|
assert len(key.key_hash) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_test_key_prefix_env():
|
||||||
|
key = generate_api_key("test")
|
||||||
|
assert key.plaintext.startswith("dfk_test_")
|
||||||
|
assert key.prefix.startswith("dfk_test_")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_rejects_bad_env():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
generate_api_key("prod") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_keys_are_unique():
|
||||||
|
a = generate_api_key("live")
|
||||||
|
b = generate_api_key("live")
|
||||||
|
assert a.plaintext != b.plaintext
|
||||||
|
assert a.key_hash != b.key_hash
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_is_deterministic_and_not_reversible():
|
||||||
|
plaintext = "dfk_live_abcdefghijklmnopqrstuvwx"
|
||||||
|
h1 = hash_api_key(plaintext)
|
||||||
|
h2 = hash_api_key(plaintext)
|
||||||
|
assert h1 == h2
|
||||||
|
assert h1 != plaintext
|
||||||
|
assert len(h1) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_prefix_takes_first_16():
|
||||||
|
assert split_prefix("dfk_live_abcdefghijklmnop") == "dfk_live_abcdefg"
|
||||||
Reference in New Issue
Block a user