mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 09:25:48 -07:00
Removed Experimental Engine
This commit is contained in:
@@ -1,63 +0,0 @@
|
||||
"""Authentication services for the Borealis Engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .device_auth_service import DeviceAuthService, DeviceRecord
|
||||
from .dpop import DPoPReplayError, DPoPVerificationError, DPoPValidator
|
||||
from .jwt_service import JWTService, load_service as load_jwt_service
|
||||
from .token_service import (
|
||||
RefreshTokenRecord,
|
||||
TokenRefreshError,
|
||||
TokenRefreshErrorCode,
|
||||
TokenService,
|
||||
)
|
||||
from .operator_account_service import (
|
||||
AccountNotFoundError,
|
||||
CannotModifySelfError,
|
||||
InvalidPasswordHashError,
|
||||
InvalidRoleError,
|
||||
LastAdminError,
|
||||
LastUserError,
|
||||
OperatorAccountError,
|
||||
OperatorAccountRecord,
|
||||
OperatorAccountService,
|
||||
UsernameAlreadyExistsError,
|
||||
)
|
||||
from .operator_auth_service import (
|
||||
InvalidCredentialsError,
|
||||
InvalidMFACodeError,
|
||||
MFAUnavailableError,
|
||||
MFASessionError,
|
||||
OperatorAuthError,
|
||||
OperatorAuthService,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeviceAuthService",
|
||||
"DeviceRecord",
|
||||
"DPoPReplayError",
|
||||
"DPoPVerificationError",
|
||||
"DPoPValidator",
|
||||
"JWTService",
|
||||
"load_jwt_service",
|
||||
"RefreshTokenRecord",
|
||||
"TokenRefreshError",
|
||||
"TokenRefreshErrorCode",
|
||||
"TokenService",
|
||||
"OperatorAccountService",
|
||||
"OperatorAccountError",
|
||||
"OperatorAccountRecord",
|
||||
"UsernameAlreadyExistsError",
|
||||
"AccountNotFoundError",
|
||||
"LastAdminError",
|
||||
"LastUserError",
|
||||
"CannotModifySelfError",
|
||||
"InvalidRoleError",
|
||||
"InvalidPasswordHashError",
|
||||
"OperatorAuthService",
|
||||
"OperatorAuthError",
|
||||
"InvalidCredentialsError",
|
||||
"InvalidMFACodeError",
|
||||
"MFAUnavailableError",
|
||||
"MFASessionError",
|
||||
]
|
||||
@@ -1,237 +0,0 @@
|
||||
"""Device authentication service copied from the legacy server stack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping, Optional, Protocol
|
||||
import logging
|
||||
|
||||
from Data.Engine.builders.device_auth import DeviceAuthRequest
|
||||
from Data.Engine.domain.device_auth import (
|
||||
AccessTokenClaims,
|
||||
DeviceAuthContext,
|
||||
DeviceAuthErrorCode,
|
||||
DeviceAuthFailure,
|
||||
DeviceFingerprint,
|
||||
DeviceGuid,
|
||||
DeviceIdentity,
|
||||
DeviceStatus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeviceAuthService",
|
||||
"DeviceRecord",
|
||||
"DPoPValidator",
|
||||
"DPoPVerificationError",
|
||||
"DPoPReplayError",
|
||||
"RateLimiter",
|
||||
"RateLimitDecision",
|
||||
"DeviceRepository",
|
||||
]
|
||||
|
||||
|
||||
class RateLimitDecision(Protocol):
|
||||
allowed: bool
|
||||
retry_after: Optional[float]
|
||||
|
||||
|
||||
class RateLimiter(Protocol):
|
||||
def check(self, key: str, max_requests: int, window_seconds: float) -> RateLimitDecision: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
|
||||
class JWTDecoder(Protocol):
|
||||
def decode(self, token: str) -> Mapping[str, object]: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
|
||||
class DPoPValidator(Protocol):
|
||||
def verify(
|
||||
self,
|
||||
method: str,
|
||||
htu: str,
|
||||
proof: str,
|
||||
access_token: Optional[str] = None,
|
||||
) -> str: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
|
||||
class DPoPVerificationError(Exception):
|
||||
"""Raised when a DPoP proof fails validation."""
|
||||
|
||||
|
||||
class DPoPReplayError(DPoPVerificationError):
|
||||
"""Raised when a DPoP proof is replayed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeviceRecord:
|
||||
"""Snapshot of a device record required for authentication."""
|
||||
|
||||
identity: DeviceIdentity
|
||||
token_version: int
|
||||
status: DeviceStatus
|
||||
|
||||
|
||||
class DeviceRepository(Protocol):
|
||||
"""Port that exposes the minimal device persistence operations."""
|
||||
|
||||
def fetch_by_guid(self, guid: DeviceGuid) -> Optional[DeviceRecord]: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
def recover_missing(
|
||||
self,
|
||||
guid: DeviceGuid,
|
||||
fingerprint: DeviceFingerprint,
|
||||
token_version: int,
|
||||
service_context: Optional[str],
|
||||
) -> Optional[DeviceRecord]: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
|
||||
class DeviceAuthService:
|
||||
"""Authenticate devices using access tokens, repositories, and DPoP proofs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
device_repository: DeviceRepository,
|
||||
jwt_service: JWTDecoder,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
rate_limiter: Optional[RateLimiter] = None,
|
||||
dpop_validator: Optional[DPoPValidator] = None,
|
||||
) -> None:
|
||||
self._repository = device_repository
|
||||
self._jwt = jwt_service
|
||||
self._log = logger or logging.getLogger("borealis.engine.auth")
|
||||
self._rate_limiter = rate_limiter
|
||||
self._dpop_validator = dpop_validator
|
||||
|
||||
def authenticate(self, request: DeviceAuthRequest, *, path: str) -> DeviceAuthContext:
|
||||
"""Authenticate an access token and return the resulting context."""
|
||||
|
||||
claims = self._decode_claims(request.access_token)
|
||||
rate_limit_key = f"fp:{claims.fingerprint.value}"
|
||||
if self._rate_limiter is not None:
|
||||
decision = self._rate_limiter.check(rate_limit_key, 60, 60.0)
|
||||
if not decision.allowed:
|
||||
raise DeviceAuthFailure(
|
||||
DeviceAuthErrorCode.RATE_LIMITED,
|
||||
http_status=429,
|
||||
retry_after=decision.retry_after,
|
||||
)
|
||||
|
||||
record = self._repository.fetch_by_guid(claims.guid)
|
||||
if record is None:
|
||||
record = self._repository.recover_missing(
|
||||
claims.guid,
|
||||
claims.fingerprint,
|
||||
claims.token_version,
|
||||
request.service_context,
|
||||
)
|
||||
|
||||
if record is None:
|
||||
raise DeviceAuthFailure(
|
||||
DeviceAuthErrorCode.DEVICE_NOT_FOUND,
|
||||
http_status=403,
|
||||
)
|
||||
|
||||
self._validate_identity(record, claims)
|
||||
|
||||
dpop_jkt = self._validate_dpop(request, record, claims)
|
||||
|
||||
context = DeviceAuthContext(
|
||||
identity=record.identity,
|
||||
access_token=request.access_token,
|
||||
claims=claims,
|
||||
status=record.status,
|
||||
service_context=request.service_context,
|
||||
dpop_jkt=dpop_jkt,
|
||||
)
|
||||
|
||||
if context.is_quarantined:
|
||||
self._log.warning(
|
||||
"device %s is quarantined; limited access for %s",
|
||||
record.identity.guid,
|
||||
path,
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def _decode_claims(self, token: str) -> AccessTokenClaims:
|
||||
try:
|
||||
raw_claims = self._jwt.decode(token)
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
if self._is_expired_signature(exc):
|
||||
raise DeviceAuthFailure(DeviceAuthErrorCode.TOKEN_EXPIRED) from exc
|
||||
raise DeviceAuthFailure(DeviceAuthErrorCode.INVALID_TOKEN) from exc
|
||||
|
||||
try:
|
||||
return AccessTokenClaims.from_mapping(raw_claims)
|
||||
except Exception as exc:
|
||||
raise DeviceAuthFailure(DeviceAuthErrorCode.INVALID_CLAIMS) from exc
|
||||
|
||||
@staticmethod
|
||||
def _is_expired_signature(exc: Exception) -> bool:
|
||||
name = exc.__class__.__name__
|
||||
return name == "ExpiredSignatureError"
|
||||
|
||||
def _validate_identity(
|
||||
self,
|
||||
record: DeviceRecord,
|
||||
claims: AccessTokenClaims,
|
||||
) -> None:
|
||||
if record.identity.guid.value != claims.guid.value:
|
||||
raise DeviceAuthFailure(
|
||||
DeviceAuthErrorCode.DEVICE_GUID_MISMATCH,
|
||||
http_status=403,
|
||||
)
|
||||
|
||||
if record.identity.fingerprint.value:
|
||||
if record.identity.fingerprint.value != claims.fingerprint.value:
|
||||
raise DeviceAuthFailure(
|
||||
DeviceAuthErrorCode.FINGERPRINT_MISMATCH,
|
||||
http_status=403,
|
||||
)
|
||||
|
||||
if record.token_version > claims.token_version:
|
||||
raise DeviceAuthFailure(DeviceAuthErrorCode.TOKEN_VERSION_REVOKED)
|
||||
|
||||
if not record.status.allows_access:
|
||||
raise DeviceAuthFailure(
|
||||
DeviceAuthErrorCode.DEVICE_REVOKED,
|
||||
http_status=403,
|
||||
)
|
||||
|
||||
def _validate_dpop(
|
||||
self,
|
||||
request: DeviceAuthRequest,
|
||||
record: DeviceRecord,
|
||||
claims: AccessTokenClaims,
|
||||
) -> Optional[str]:
|
||||
if not request.dpop_proof:
|
||||
return None
|
||||
|
||||
if self._dpop_validator is None:
|
||||
raise DeviceAuthFailure(
|
||||
DeviceAuthErrorCode.DPOP_NOT_SUPPORTED,
|
||||
http_status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
return self._dpop_validator.verify(
|
||||
request.http_method,
|
||||
request.htu,
|
||||
request.dpop_proof,
|
||||
request.access_token,
|
||||
)
|
||||
except DPoPReplayError as exc:
|
||||
raise DeviceAuthFailure(
|
||||
DeviceAuthErrorCode.DPOP_REPLAYED,
|
||||
http_status=400,
|
||||
) from exc
|
||||
except DPoPVerificationError as exc:
|
||||
raise DeviceAuthFailure(
|
||||
DeviceAuthErrorCode.DPOP_INVALID,
|
||||
http_status=400,
|
||||
) from exc
|
||||
@@ -1,105 +0,0 @@
|
||||
"""DPoP proof validation for Engine services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from threading import Lock
|
||||
from typing import Dict, Optional
|
||||
|
||||
import jwt
|
||||
|
||||
__all__ = ["DPoPValidator", "DPoPVerificationError", "DPoPReplayError"]
|
||||
|
||||
|
||||
_DP0P_MAX_SKEW = 300.0
|
||||
|
||||
|
||||
class DPoPVerificationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DPoPReplayError(DPoPVerificationError):
|
||||
pass
|
||||
|
||||
|
||||
class DPoPValidator:
|
||||
def __init__(self) -> None:
|
||||
self._observed_jti: Dict[str, float] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
def verify(
|
||||
self,
|
||||
method: str,
|
||||
htu: str,
|
||||
proof: str,
|
||||
access_token: Optional[str] = None,
|
||||
) -> str:
|
||||
if not proof:
|
||||
raise DPoPVerificationError("DPoP proof missing")
|
||||
|
||||
try:
|
||||
header = jwt.get_unverified_header(proof)
|
||||
except Exception as exc:
|
||||
raise DPoPVerificationError("invalid DPoP header") from exc
|
||||
|
||||
jwk = header.get("jwk")
|
||||
alg = header.get("alg")
|
||||
if not jwk or not isinstance(jwk, dict):
|
||||
raise DPoPVerificationError("missing jwk in DPoP header")
|
||||
if alg not in ("EdDSA", "ES256", "ES384", "ES512"):
|
||||
raise DPoPVerificationError(f"unsupported DPoP alg {alg}")
|
||||
|
||||
try:
|
||||
key = jwt.PyJWK(jwk)
|
||||
public_key = key.key
|
||||
except Exception as exc:
|
||||
raise DPoPVerificationError("invalid jwk in DPoP header") from exc
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
proof,
|
||||
public_key,
|
||||
algorithms=[alg],
|
||||
options={"require": ["htm", "htu", "jti", "iat"]},
|
||||
)
|
||||
except Exception as exc:
|
||||
raise DPoPVerificationError("invalid DPoP signature") from exc
|
||||
|
||||
htm = claims.get("htm")
|
||||
proof_htu = claims.get("htu")
|
||||
jti = claims.get("jti")
|
||||
iat = claims.get("iat")
|
||||
ath = claims.get("ath")
|
||||
|
||||
if not isinstance(htm, str) or htm.lower() != method.lower():
|
||||
raise DPoPVerificationError("DPoP htm mismatch")
|
||||
if not isinstance(proof_htu, str) or proof_htu != htu:
|
||||
raise DPoPVerificationError("DPoP htu mismatch")
|
||||
if not isinstance(jti, str):
|
||||
raise DPoPVerificationError("DPoP jti missing")
|
||||
if not isinstance(iat, (int, float)):
|
||||
raise DPoPVerificationError("DPoP iat missing")
|
||||
|
||||
now = time.time()
|
||||
if abs(now - float(iat)) > _DP0P_MAX_SKEW:
|
||||
raise DPoPVerificationError("DPoP proof outside allowed skew")
|
||||
|
||||
if ath and access_token:
|
||||
expected_ath = jwt.utils.base64url_encode(
|
||||
hashlib.sha256(access_token.encode("utf-8")).digest()
|
||||
).decode("ascii")
|
||||
if expected_ath != ath:
|
||||
raise DPoPVerificationError("DPoP ath mismatch")
|
||||
|
||||
with self._lock:
|
||||
expiry = self._observed_jti.get(jti)
|
||||
if expiry and expiry > now:
|
||||
raise DPoPReplayError("DPoP proof replay detected")
|
||||
self._observed_jti[jti] = now + _DP0P_MAX_SKEW
|
||||
stale = [key for key, exp in self._observed_jti.items() if exp <= now]
|
||||
for key in stale:
|
||||
self._observed_jti.pop(key, None)
|
||||
|
||||
thumbprint = jwt.PyJWK(jwk).thumbprint()
|
||||
return thumbprint.decode("ascii")
|
||||
@@ -1,124 +0,0 @@
|
||||
"""JWT issuance utilities for the Engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from Data.Engine.runtime import ensure_runtime_dir, runtime_path
|
||||
|
||||
__all__ = ["JWTService", "load_service"]
|
||||
|
||||
|
||||
_KEY_DIR = runtime_path("auth_keys")
|
||||
_KEY_FILE = _KEY_DIR / "engine-jwt-ed25519.key"
|
||||
_LEGACY_KEY_FILE = runtime_path("keys") / "borealis-jwt-ed25519.key"
|
||||
|
||||
|
||||
class JWTService:
|
||||
def __init__(self, private_key: ed25519.Ed25519PrivateKey, key_id: str) -> None:
|
||||
self._private_key = private_key
|
||||
self._public_key = private_key.public_key()
|
||||
self._key_id = key_id
|
||||
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
return self._key_id
|
||||
|
||||
def issue_access_token(
|
||||
self,
|
||||
guid: str,
|
||||
ssl_key_fingerprint: str,
|
||||
token_version: int,
|
||||
expires_in: int = 900,
|
||||
extra_claims: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
now = int(time.time())
|
||||
payload: Dict[str, Any] = {
|
||||
"sub": f"device:{guid}",
|
||||
"guid": guid,
|
||||
"ssl_key_fingerprint": ssl_key_fingerprint,
|
||||
"token_version": int(token_version),
|
||||
"iat": now,
|
||||
"nbf": now,
|
||||
"exp": now + int(expires_in),
|
||||
}
|
||||
if extra_claims:
|
||||
payload.update(extra_claims)
|
||||
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
self._private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
),
|
||||
algorithm="EdDSA",
|
||||
headers={"kid": self._key_id},
|
||||
)
|
||||
return token
|
||||
|
||||
def decode(self, token: str, *, audience: Optional[str] = None) -> Dict[str, Any]:
|
||||
options = {"require": ["exp", "iat", "sub"]}
|
||||
public_pem = self._public_key.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
return jwt.decode(
|
||||
token,
|
||||
public_pem,
|
||||
algorithms=["EdDSA"],
|
||||
audience=audience,
|
||||
options=options,
|
||||
)
|
||||
|
||||
def public_jwk(self) -> Dict[str, Any]:
|
||||
public_bytes = self._public_key.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
jwk_x = jwt.utils.base64url_encode(public_bytes).decode("ascii")
|
||||
return {"kty": "OKP", "crv": "Ed25519", "kid": self._key_id, "alg": "EdDSA", "use": "sig", "x": jwk_x}
|
||||
|
||||
|
||||
def load_service() -> JWTService:
|
||||
private_key = _load_or_create_private_key()
|
||||
public_bytes = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
key_id = hashlib.sha256(public_bytes).hexdigest()[:16]
|
||||
return JWTService(private_key, key_id)
|
||||
|
||||
|
||||
def _load_or_create_private_key() -> ed25519.Ed25519PrivateKey:
|
||||
ensure_runtime_dir("auth_keys")
|
||||
|
||||
if _KEY_FILE.exists():
|
||||
with _KEY_FILE.open("rb") as fh:
|
||||
return serialization.load_pem_private_key(fh.read(), password=None)
|
||||
|
||||
if _LEGACY_KEY_FILE.exists():
|
||||
with _LEGACY_KEY_FILE.open("rb") as fh:
|
||||
return serialization.load_pem_private_key(fh.read(), password=None)
|
||||
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
_KEY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with _KEY_FILE.open("wb") as fh:
|
||||
fh.write(pem)
|
||||
try:
|
||||
if hasattr(_KEY_FILE, "chmod"):
|
||||
_KEY_FILE.chmod(0o600)
|
||||
except Exception:
|
||||
pass
|
||||
return private_key
|
||||
@@ -1,211 +0,0 @@
|
||||
"""Operator account management service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from Data.Engine.domain import OperatorAccount
|
||||
from Data.Engine.repositories.sqlite.user_repository import SQLiteUserRepository
|
||||
|
||||
|
||||
class OperatorAccountError(Exception):
|
||||
"""Base class for operator account management failures."""
|
||||
|
||||
|
||||
class UsernameAlreadyExistsError(OperatorAccountError):
|
||||
"""Raised when attempting to create an operator with a duplicate username."""
|
||||
|
||||
|
||||
class AccountNotFoundError(OperatorAccountError):
|
||||
"""Raised when the requested operator account cannot be located."""
|
||||
|
||||
|
||||
class LastAdminError(OperatorAccountError):
|
||||
"""Raised when attempting to demote or delete the last remaining admin."""
|
||||
|
||||
|
||||
class LastUserError(OperatorAccountError):
|
||||
"""Raised when attempting to delete the final operator account."""
|
||||
|
||||
|
||||
class CannotModifySelfError(OperatorAccountError):
|
||||
"""Raised when the caller attempts to delete themselves."""
|
||||
|
||||
|
||||
class InvalidRoleError(OperatorAccountError):
|
||||
"""Raised when a role value is invalid."""
|
||||
|
||||
|
||||
class InvalidPasswordHashError(OperatorAccountError):
|
||||
"""Raised when a password hash is malformed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperatorAccountRecord:
|
||||
username: str
|
||||
display_name: str
|
||||
role: str
|
||||
last_login: int
|
||||
created_at: int
|
||||
updated_at: int
|
||||
mfa_enabled: bool
|
||||
|
||||
|
||||
class OperatorAccountService:
|
||||
"""High-level operations for managing operator accounts."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SQLiteUserRepository,
|
||||
*,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> None:
|
||||
self._repository = repository
|
||||
self._log = logger or logging.getLogger("borealis.engine.services.operator_accounts")
|
||||
|
||||
def list_accounts(self) -> list[OperatorAccountRecord]:
|
||||
return [_to_record(account) for account in self._repository.list_accounts()]
|
||||
|
||||
def create_account(
|
||||
self,
|
||||
*,
|
||||
username: str,
|
||||
password_sha512: str,
|
||||
role: str,
|
||||
display_name: Optional[str] = None,
|
||||
) -> OperatorAccountRecord:
|
||||
normalized_role = self._normalize_role(role)
|
||||
username = (username or "").strip()
|
||||
password_sha512 = (password_sha512 or "").strip().lower()
|
||||
display_name = (display_name or username or "").strip()
|
||||
|
||||
if not username or not password_sha512:
|
||||
raise InvalidPasswordHashError("username and password are required")
|
||||
if len(password_sha512) != 128:
|
||||
raise InvalidPasswordHashError("password hash must be 128 hex characters")
|
||||
|
||||
now = int(time.time())
|
||||
try:
|
||||
self._repository.create_account(
|
||||
username=username,
|
||||
display_name=display_name or username,
|
||||
password_sha512=password_sha512,
|
||||
role=normalized_role,
|
||||
timestamp=now,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - sqlite integrity errors are deterministic
|
||||
import sqlite3
|
||||
|
||||
if isinstance(exc, sqlite3.IntegrityError):
|
||||
raise UsernameAlreadyExistsError("username already exists") from exc
|
||||
raise
|
||||
|
||||
account = self._repository.fetch_by_username(username)
|
||||
if not account: # pragma: no cover - sanity guard
|
||||
raise AccountNotFoundError("account creation failed")
|
||||
return _to_record(account)
|
||||
|
||||
def delete_account(self, username: str, *, actor: Optional[str] = None) -> None:
|
||||
username = (username or "").strip()
|
||||
if not username:
|
||||
raise AccountNotFoundError("invalid username")
|
||||
|
||||
if actor and actor.strip().lower() == username.lower():
|
||||
raise CannotModifySelfError("cannot delete yourself")
|
||||
|
||||
total_accounts = self._repository.count_accounts()
|
||||
if total_accounts <= 1:
|
||||
raise LastUserError("cannot delete the last user")
|
||||
|
||||
target = self._repository.fetch_by_username(username)
|
||||
if not target:
|
||||
raise AccountNotFoundError("user not found")
|
||||
|
||||
if target.role.lower() == "admin" and self._repository.count_admins() <= 1:
|
||||
raise LastAdminError("cannot delete the last admin")
|
||||
|
||||
if not self._repository.delete_account(username):
|
||||
raise AccountNotFoundError("user not found")
|
||||
|
||||
def reset_password(self, username: str, password_sha512: str) -> None:
|
||||
username = (username or "").strip()
|
||||
password_sha512 = (password_sha512 or "").strip().lower()
|
||||
if len(password_sha512) != 128:
|
||||
raise InvalidPasswordHashError("invalid password hash")
|
||||
|
||||
now = int(time.time())
|
||||
if not self._repository.update_password(username, password_sha512, timestamp=now):
|
||||
raise AccountNotFoundError("user not found")
|
||||
|
||||
def change_role(self, username: str, role: str, *, actor: Optional[str] = None) -> OperatorAccountRecord:
|
||||
username = (username or "").strip()
|
||||
normalized_role = self._normalize_role(role)
|
||||
|
||||
account = self._repository.fetch_by_username(username)
|
||||
if not account:
|
||||
raise AccountNotFoundError("user not found")
|
||||
|
||||
if account.role.lower() == "admin" and normalized_role.lower() != "admin":
|
||||
if self._repository.count_admins() <= 1:
|
||||
raise LastAdminError("cannot demote the last admin")
|
||||
|
||||
now = int(time.time())
|
||||
if not self._repository.update_role(username, normalized_role, timestamp=now):
|
||||
raise AccountNotFoundError("user not found")
|
||||
|
||||
updated = self._repository.fetch_by_username(username)
|
||||
if not updated: # pragma: no cover - guard
|
||||
raise AccountNotFoundError("user not found")
|
||||
|
||||
record = _to_record(updated)
|
||||
if actor and actor.strip().lower() == username.lower():
|
||||
self._log.info("actor-role-updated", extra={"username": username, "role": record.role})
|
||||
return record
|
||||
|
||||
def update_mfa(self, username: str, *, enabled: bool, reset_secret: bool) -> None:
|
||||
username = (username or "").strip()
|
||||
if not username:
|
||||
raise AccountNotFoundError("invalid username")
|
||||
|
||||
now = int(time.time())
|
||||
if not self._repository.update_mfa(username, enabled=enabled, reset_secret=reset_secret, timestamp=now):
|
||||
raise AccountNotFoundError("user not found")
|
||||
|
||||
def fetch_account(self, username: str) -> Optional[OperatorAccountRecord]:
|
||||
account = self._repository.fetch_by_username(username)
|
||||
return _to_record(account) if account else None
|
||||
|
||||
def _normalize_role(self, role: str) -> str:
|
||||
normalized = (role or "").strip().title() or "User"
|
||||
if normalized not in {"User", "Admin"}:
|
||||
raise InvalidRoleError("invalid role")
|
||||
return normalized
|
||||
|
||||
|
||||
def _to_record(account: OperatorAccount) -> OperatorAccountRecord:
|
||||
return OperatorAccountRecord(
|
||||
username=account.username,
|
||||
display_name=account.display_name or account.username,
|
||||
role=account.role or "User",
|
||||
last_login=int(account.last_login or 0),
|
||||
created_at=int(account.created_at or 0),
|
||||
updated_at=int(account.updated_at or 0),
|
||||
mfa_enabled=bool(account.mfa_enabled),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OperatorAccountService",
|
||||
"OperatorAccountError",
|
||||
"UsernameAlreadyExistsError",
|
||||
"AccountNotFoundError",
|
||||
"LastAdminError",
|
||||
"LastUserError",
|
||||
"CannotModifySelfError",
|
||||
"InvalidRoleError",
|
||||
"InvalidPasswordHashError",
|
||||
"OperatorAccountRecord",
|
||||
]
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Operator authentication service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
try: # pragma: no cover - optional dependencies mirror legacy server behaviour
|
||||
import pyotp # type: ignore
|
||||
except Exception: # pragma: no cover - gracefully degrade when unavailable
|
||||
pyotp = None # type: ignore
|
||||
|
||||
try: # pragma: no cover - optional dependency
|
||||
import qrcode # type: ignore
|
||||
except Exception: # pragma: no cover - gracefully degrade when unavailable
|
||||
qrcode = None # type: ignore
|
||||
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from Data.Engine.builders.operator_auth import (
|
||||
OperatorLoginRequest,
|
||||
OperatorMFAVerificationRequest,
|
||||
)
|
||||
from Data.Engine.domain import (
|
||||
OperatorAccount,
|
||||
OperatorLoginSuccess,
|
||||
OperatorMFAChallenge,
|
||||
)
|
||||
from Data.Engine.repositories.sqlite.user_repository import SQLiteUserRepository
|
||||
|
||||
|
||||
class OperatorAuthError(Exception):
|
||||
"""Base class for operator authentication errors."""
|
||||
|
||||
|
||||
class InvalidCredentialsError(OperatorAuthError):
|
||||
"""Raised when username/password verification fails."""
|
||||
|
||||
|
||||
class MFAUnavailableError(OperatorAuthError):
|
||||
"""Raised when MFA functionality is requested but dependencies are missing."""
|
||||
|
||||
|
||||
class InvalidMFACodeError(OperatorAuthError):
|
||||
"""Raised when the submitted MFA code is invalid."""
|
||||
|
||||
|
||||
class MFASessionError(OperatorAuthError):
|
||||
"""Raised when the MFA session state cannot be validated."""
|
||||
|
||||
|
||||
class OperatorAuthService:
|
||||
"""Authenticate operator accounts and manage MFA challenges."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: SQLiteUserRepository,
|
||||
*,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> None:
|
||||
self._repository = repository
|
||||
self._log = logger or logging.getLogger("borealis.engine.services.operator_auth")
|
||||
|
||||
def authenticate(
|
||||
self, request: OperatorLoginRequest
|
||||
) -> OperatorLoginSuccess | OperatorMFAChallenge:
|
||||
account = self._repository.fetch_by_username(request.username)
|
||||
if not account:
|
||||
raise InvalidCredentialsError("invalid username or password")
|
||||
|
||||
if not self._password_matches(account, request.password_sha512):
|
||||
raise InvalidCredentialsError("invalid username or password")
|
||||
|
||||
if not account.mfa_enabled:
|
||||
return self._finalize_login(account)
|
||||
|
||||
stage = "verify" if account.mfa_secret else "setup"
|
||||
return self._build_mfa_challenge(account, stage)
|
||||
|
||||
def verify_mfa(
|
||||
self,
|
||||
challenge: OperatorMFAChallenge,
|
||||
request: OperatorMFAVerificationRequest,
|
||||
) -> OperatorLoginSuccess:
|
||||
now = int(time.time())
|
||||
if challenge.pending_token != request.pending_token:
|
||||
raise MFASessionError("invalid_session")
|
||||
if challenge.expires_at < now:
|
||||
raise MFASessionError("expired")
|
||||
|
||||
if challenge.stage == "setup":
|
||||
secret = (challenge.secret or "").strip()
|
||||
if not secret:
|
||||
raise MFASessionError("mfa_not_configured")
|
||||
totp = self._totp_for_secret(secret)
|
||||
if not totp.verify(request.code, valid_window=1):
|
||||
raise InvalidMFACodeError("invalid_code")
|
||||
self._repository.store_mfa_secret(challenge.username, secret, timestamp=now)
|
||||
else:
|
||||
account = self._repository.fetch_by_username(challenge.username)
|
||||
if not account or not account.mfa_secret:
|
||||
raise MFASessionError("mfa_not_configured")
|
||||
totp = self._totp_for_secret(account.mfa_secret)
|
||||
if not totp.verify(request.code, valid_window=1):
|
||||
raise InvalidMFACodeError("invalid_code")
|
||||
|
||||
account = self._repository.fetch_by_username(challenge.username)
|
||||
if not account:
|
||||
raise InvalidCredentialsError("invalid username or password")
|
||||
return self._finalize_login(account)
|
||||
|
||||
def issue_token(self, username: str, role: str) -> str:
|
||||
serializer = self._token_serializer()
|
||||
payload = {"u": username, "r": role or "User", "ts": int(time.time())}
|
||||
return serializer.dumps(payload)
|
||||
|
||||
def resolve_token(self, token: str, *, max_age: int = 30 * 24 * 3600) -> Optional[OperatorAccount]:
|
||||
"""Return the account associated with *token* if it is valid."""
|
||||
|
||||
token = (token or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
serializer = self._token_serializer()
|
||||
try:
|
||||
payload = serializer.loads(token, max_age=max_age)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
|
||||
username = str(payload.get("u") or "").strip()
|
||||
if not username:
|
||||
return None
|
||||
|
||||
return self._repository.fetch_by_username(username)
|
||||
|
||||
def fetch_account(self, username: str) -> Optional[OperatorAccount]:
|
||||
"""Return the operator account for *username* if it exists."""
|
||||
|
||||
username = (username or "").strip()
|
||||
if not username:
|
||||
return None
|
||||
return self._repository.fetch_by_username(username)
|
||||
|
||||
def _finalize_login(self, account: OperatorAccount) -> OperatorLoginSuccess:
|
||||
now = int(time.time())
|
||||
self._repository.update_last_login(account.username, now)
|
||||
token = self.issue_token(account.username, account.role)
|
||||
return OperatorLoginSuccess(username=account.username, role=account.role, token=token)
|
||||
|
||||
def _password_matches(self, account: OperatorAccount, provided_hash: str) -> bool:
|
||||
expected = (account.password_sha512 or "").strip().lower()
|
||||
candidate = (provided_hash or "").strip().lower()
|
||||
return bool(expected and candidate and expected == candidate)
|
||||
|
||||
def _build_mfa_challenge(
|
||||
self,
|
||||
account: OperatorAccount,
|
||||
stage: str,
|
||||
) -> OperatorMFAChallenge:
|
||||
now = int(time.time())
|
||||
pending_token = uuid.uuid4().hex
|
||||
secret = None
|
||||
otpauth_url = None
|
||||
qr_image = None
|
||||
|
||||
if stage == "setup":
|
||||
secret = self._generate_totp_secret()
|
||||
otpauth_url = self._totp_provisioning_uri(secret, account.username)
|
||||
qr_image = self._totp_qr_data_uri(otpauth_url) if otpauth_url else None
|
||||
|
||||
return OperatorMFAChallenge(
|
||||
username=account.username,
|
||||
role=account.role,
|
||||
stage="verify" if stage == "verify" else "setup",
|
||||
pending_token=pending_token,
|
||||
expires_at=now + 300,
|
||||
secret=secret,
|
||||
otpauth_url=otpauth_url,
|
||||
qr_image=qr_image,
|
||||
)
|
||||
|
||||
def _token_serializer(self) -> URLSafeTimedSerializer:
|
||||
secret = os.getenv("BOREALIS_FLASK_SECRET_KEY") or "change-me"
|
||||
return URLSafeTimedSerializer(secret, salt="borealis-auth")
|
||||
|
||||
def _generate_totp_secret(self) -> str:
|
||||
if not pyotp:
|
||||
raise MFAUnavailableError("pyotp is not installed; MFA unavailable")
|
||||
return pyotp.random_base32() # type: ignore[no-any-return]
|
||||
|
||||
def _totp_for_secret(self, secret: str):
|
||||
if not pyotp:
|
||||
raise MFAUnavailableError("pyotp is not installed; MFA unavailable")
|
||||
normalized = secret.replace(" ", "").strip().upper()
|
||||
if not normalized:
|
||||
raise MFASessionError("mfa_not_configured")
|
||||
return pyotp.TOTP(normalized, digits=6, interval=30)
|
||||
|
||||
def _totp_provisioning_uri(self, secret: str, username: str) -> Optional[str]:
|
||||
try:
|
||||
totp = self._totp_for_secret(secret)
|
||||
except OperatorAuthError:
|
||||
return None
|
||||
issuer = os.getenv("BOREALIS_MFA_ISSUER", "Borealis")
|
||||
try:
|
||||
return totp.provisioning_uri(name=username, issuer_name=issuer)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return None
|
||||
|
||||
def _totp_qr_data_uri(self, payload: str) -> Optional[str]:
|
||||
if not payload or qrcode is None:
|
||||
return None
|
||||
try:
|
||||
img = qrcode.make(payload, box_size=6, border=4)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
encoded = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
except Exception: # pragma: no cover - defensive
|
||||
self._log.warning("failed to generate MFA QR code", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OperatorAuthService",
|
||||
"OperatorAuthError",
|
||||
"InvalidCredentialsError",
|
||||
"MFAUnavailableError",
|
||||
"InvalidMFACodeError",
|
||||
"MFASessionError",
|
||||
]
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Token refresh service extracted from the legacy blueprint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Protocol
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from Data.Engine.builders.device_auth import RefreshTokenRequest
|
||||
from Data.Engine.domain.device_auth import DeviceGuid
|
||||
|
||||
from .device_auth_service import (
|
||||
DeviceRecord,
|
||||
DeviceRepository,
|
||||
DPoPReplayError,
|
||||
DPoPVerificationError,
|
||||
DPoPValidator,
|
||||
)
|
||||
|
||||
__all__ = ["RefreshTokenRecord", "TokenService", "TokenRefreshError", "TokenRefreshErrorCode"]
|
||||
|
||||
|
||||
class JWTIssuer(Protocol):
|
||||
def issue_access_token(self, guid: str, fingerprint: str, token_version: int) -> str: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
|
||||
class TokenRefreshErrorCode(str):
|
||||
INVALID_REFRESH_TOKEN = "invalid_refresh_token"
|
||||
REFRESH_TOKEN_REVOKED = "refresh_token_revoked"
|
||||
REFRESH_TOKEN_EXPIRED = "refresh_token_expired"
|
||||
DEVICE_NOT_FOUND = "device_not_found"
|
||||
DEVICE_REVOKED = "device_revoked"
|
||||
DPOP_REPLAYED = "dpop_replayed"
|
||||
DPOP_INVALID = "dpop_invalid"
|
||||
|
||||
|
||||
class TokenRefreshError(Exception):
|
||||
def __init__(self, code: str, *, http_status: int = 400) -> None:
|
||||
self.code = code
|
||||
self.http_status = http_status
|
||||
super().__init__(code)
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {"error": self.code}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RefreshTokenRecord:
|
||||
record_id: str
|
||||
guid: DeviceGuid
|
||||
token_hash: str
|
||||
dpop_jkt: Optional[str]
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime]
|
||||
revoked_at: Optional[datetime]
|
||||
|
||||
@classmethod
|
||||
def from_row(
|
||||
cls,
|
||||
*,
|
||||
record_id: str,
|
||||
guid: DeviceGuid,
|
||||
token_hash: str,
|
||||
dpop_jkt: Optional[str],
|
||||
created_at: datetime,
|
||||
expires_at: Optional[datetime],
|
||||
revoked_at: Optional[datetime],
|
||||
) -> "RefreshTokenRecord":
|
||||
return cls(
|
||||
record_id=record_id,
|
||||
guid=guid,
|
||||
token_hash=token_hash,
|
||||
dpop_jkt=dpop_jkt,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
revoked_at=revoked_at,
|
||||
)
|
||||
|
||||
|
||||
class RefreshTokenRepository(Protocol):
|
||||
def fetch(self, guid: DeviceGuid, token_hash: str) -> Optional[RefreshTokenRecord]: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
def clear_dpop_binding(self, record_id: str) -> None: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
def touch(self, record_id: str, *, last_used_at: datetime, dpop_jkt: Optional[str]) -> None: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessTokenResponse:
|
||||
access_token: str
|
||||
expires_in: int
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
refresh_token_repository: RefreshTokenRepository,
|
||||
device_repository: DeviceRepository,
|
||||
jwt_service: JWTIssuer,
|
||||
dpop_validator: Optional[DPoPValidator] = None,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> None:
|
||||
self._refresh_tokens = refresh_token_repository
|
||||
self._devices = device_repository
|
||||
self._jwt = jwt_service
|
||||
self._dpop_validator = dpop_validator
|
||||
self._log = logger or logging.getLogger("borealis.engine.auth")
|
||||
|
||||
def refresh_access_token(
|
||||
self,
|
||||
request: RefreshTokenRequest,
|
||||
) -> AccessTokenResponse:
|
||||
record = self._refresh_tokens.fetch(
|
||||
request.guid,
|
||||
self._hash_token(request.refresh_token),
|
||||
)
|
||||
if record is None:
|
||||
raise TokenRefreshError(TokenRefreshErrorCode.INVALID_REFRESH_TOKEN, http_status=401)
|
||||
|
||||
if record.guid.value != request.guid.value:
|
||||
raise TokenRefreshError(TokenRefreshErrorCode.INVALID_REFRESH_TOKEN, http_status=401)
|
||||
|
||||
if record.revoked_at is not None:
|
||||
raise TokenRefreshError(TokenRefreshErrorCode.REFRESH_TOKEN_REVOKED, http_status=401)
|
||||
|
||||
if record.expires_at is not None and record.expires_at <= self._now():
|
||||
raise TokenRefreshError(TokenRefreshErrorCode.REFRESH_TOKEN_EXPIRED, http_status=401)
|
||||
|
||||
device = self._devices.fetch_by_guid(request.guid)
|
||||
if device is None:
|
||||
raise TokenRefreshError(TokenRefreshErrorCode.DEVICE_NOT_FOUND, http_status=404)
|
||||
|
||||
if not device.status.allows_access:
|
||||
raise TokenRefreshError(TokenRefreshErrorCode.DEVICE_REVOKED, http_status=403)
|
||||
|
||||
dpop_jkt = record.dpop_jkt or ""
|
||||
if request.dpop_proof:
|
||||
if self._dpop_validator is None:
|
||||
raise TokenRefreshError(TokenRefreshErrorCode.DPOP_INVALID)
|
||||
try:
|
||||
dpop_jkt = self._dpop_validator.verify(
|
||||
request.http_method,
|
||||
request.htu,
|
||||
request.dpop_proof,
|
||||
None,
|
||||
)
|
||||
except DPoPReplayError as exc:
|
||||
raise TokenRefreshError(TokenRefreshErrorCode.DPOP_REPLAYED) from exc
|
||||
except DPoPVerificationError as exc:
|
||||
raise TokenRefreshError(TokenRefreshErrorCode.DPOP_INVALID) from exc
|
||||
elif record.dpop_jkt:
|
||||
self._log.warning(
|
||||
"Clearing stored DPoP binding for guid=%s due to missing proof",
|
||||
request.guid.value,
|
||||
)
|
||||
self._refresh_tokens.clear_dpop_binding(record.record_id)
|
||||
|
||||
access_token = self._jwt.issue_access_token(
|
||||
request.guid.value,
|
||||
device.identity.fingerprint.value,
|
||||
max(device.token_version, 1),
|
||||
)
|
||||
|
||||
self._refresh_tokens.touch(
|
||||
record.record_id,
|
||||
last_used_at=self._now(),
|
||||
dpop_jkt=dpop_jkt or None,
|
||||
)
|
||||
|
||||
return AccessTokenResponse(
|
||||
access_token=access_token,
|
||||
expires_in=900,
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _now() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
Reference in New Issue
Block a user