Fix(Feat): CA, Audits, Rte Limit

CA Encryption, Serials, Rate Limiter, Account suspension blocks login
Transfer Ownership & Delete Account
This commit is contained in:
2026-03-02 23:53:51 +05:45
parent be87fd90b1
commit 5250d18eb0
23 changed files with 1399 additions and 34 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ class AuditService:
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
metadata=metadata,
extra_data=metadata,
description=description,
success=success,
error_message=error_message,
+32 -1
View File
@@ -102,7 +102,7 @@ class AuthService:
if current_app.config.get('ENV') == 'development':
logger.debug(f"[Auth] Account status: user_id={user.id}, status={user.status}")
if user.status == UserStatus.SUSPENDED:
if user.status in (UserStatus.SUSPENDED, UserStatus.COMPLIANCE_SUSPENDED):
raise AccountSuspendedError()
if user.status == UserStatus.INACTIVE:
raise AccountInactiveError()
@@ -210,6 +210,22 @@ class AuthService:
auth_method.password_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
db.session.commit()
# Invalidate all other sessions so that if an attacker had a valid
# session token, changing the password actually locks them out.
# The current request's session (if any) is preserved so the user
# doesn't have to log in again immediately.
from flask import g as flask_g
current_session_id = getattr(flask_g, "current_session", None)
current_session_id = current_session_id.id if current_session_id else None
sessions_to_revoke = Session.query.filter(
Session.user_id == user.id,
Session.revoked_at == None, # noqa: E711
).all()
for sess in sessions_to_revoke:
if sess.id != current_session_id:
sess.revoke(reason="Password changed")
db.session.commit()
# Log password change
AuditService.log_action(
action=AuditAction.PASSWORD_CHANGE,
@@ -482,9 +498,24 @@ class AuthService:
if not secret:
raise InvalidCredentialsError("TOTP secret not found")
# Replay-attack prevention: reject codes that have already been
# accepted within the current validity window.
if TOTPService.is_code_already_used(str(user.id), code):
AuditService.log_action(
action=AuditAction.TOTP_VERIFY_FAILED,
user_id=user.id,
resource_type="authentication_method",
resource_id=auth_method.id,
description="TOTP code replay attempt detected",
)
raise InvalidCredentialsError("Invalid TOTP code")
is_valid = TOTPService.verify_code(secret, code, client_utc_timestamp=client_utc_timestamp)
if is_valid:
# Mark this code as used to prevent replay within the validity window
TOTPService.mark_code_used(str(user.id), code)
auth_method.last_used_at = datetime.now(timezone.utc)
db.session.commit()
@@ -736,9 +736,14 @@ class ExternalAuthService:
400,
)
# Generate PKCE
code_verifier = secrets.token_urlsafe(32)
code_challenge = cls._compute_s256_challenge(code_verifier)
# Generate PKCE — skip for confidential clients (Google, Microsoft) that use a
# client_secret. Sending code_challenge to Microsoft causes it to enforce PKCE on
# the token exchange, which then fails. Matches the behaviour of initiate_login_flow.
code_verifier = None
code_challenge = None
if provider_type_str not in ('google', 'microsoft'):
code_verifier = secrets.token_urlsafe(32)
code_challenge = cls._compute_s256_challenge(code_verifier)
# Create OAuth state
state = OAuthState.create_state(
+20 -2
View File
@@ -188,11 +188,10 @@ class OrganizationService:
Raises:
ConflictError: If user is already a member
"""
# Check if already a member
# Check if already a member (active or soft-deleted — both blocked by DB unique constraint)
existing = OrganizationMember.query.filter_by(
user_id=user_id,
organization_id=org.id,
deleted_at=None,
).first()
# Development-only debug logging for membership validation
@@ -200,6 +199,25 @@ class OrganizationService:
logger.debug(f"[Org] Member check: org_id={org.id}, user_id={user_id}, already_member={existing is not None}")
if existing:
if existing.deleted_at is not None:
# Reactivate the soft-deleted membership with the new role
existing.deleted_at = None
existing.role = role
existing.invited_by_id = inviter_id
existing.invited_at = datetime.now(timezone.utc)
existing.joined_at = datetime.now(timezone.utc)
existing.save()
AuditService.log_action(
action=AuditAction.ORG_MEMBER_ADD,
user_id=inviter_id,
organization_id=org.id,
resource_type="organization_member",
resource_id=existing.id,
metadata={"added_user_id": user_id, "role": role.value},
description=f"Member re-added to organization with role: {role.value}",
)
return existing
raise ConflictError("User is already a member of this organization")
# Create membership
@@ -192,13 +192,19 @@ class SSHCASigningService:
self,
signing_request: SSHCertificateSigningRequest,
ca_private_key: Optional[str] = None,
) -> SSHCertificateSigningResponse:
ca_obj=None,
) -> "SSHCertificateSigningResponse":
"""Sign an SSH certificate.
Args:
signing_request: SSHCertificateSigningRequest instance
ca_private_key: CA private key in PEM format. If not provided,
loaded from config (ca_key_path or SSH_CA_PRIVATE_KEY env var)
ca_obj: Optional CA model instance. When supplied its monotonic
serial counter is incremented atomically (SELECT FOR UPDATE)
and the resulting integer is embedded in the certificate's
serial field. This ensures every issued cert has a unique,
ordered, auditable serial number.
Returns:
SSHCertificateSigningResponse with signed certificate
@@ -245,13 +251,27 @@ class SSHCASigningService:
valid_before = now + timedelta(hours=expiry_hours)
# Set certificate fields
cert_type = 1 if signing_request.cert_type == "user" else 0
# sshkey-tools: user=1, host=2 (not 0)
cert_type = 1 if signing_request.cert_type == "user" else 2
certificate.fields.cert_type = cert_type
certificate.fields.key_id = signing_request.key_id
certificate.fields.principals = signing_request.principals
certificate.fields.valid_after = now
certificate.fields.valid_before = valid_before
# ── Serial number ────────────────────────────────────────────────
# If a CA object is provided, use its monotonic counter so every
# certificate gets a unique, ordered, auditable serial. The
# counter increment is flushed inside get_next_serial(); the
# caller's commit() persists it atomically with the cert record.
if ca_obj is not None:
assigned_serial = ca_obj.get_next_serial()
certificate.fields.serial = assigned_serial
self.logger.debug(
f"Assigned serial {assigned_serial} from CA {ca_obj.id}"
)
# ─────────────────────────────────────────────────────────────────
# Set extensions — prefer policy-provided list, fall back to standard set
extensions = signing_request.extensions
@@ -276,8 +296,13 @@ class SSHCASigningService:
self.logger.error(f"Certificate verification failed: {str(e)}")
raise SSHCASigningError(f"Certificate verification failed: {str(e)}")
# Extract serial from certificate
serial = str(certificate.fields.serial).split(":")[-1].strip() if hasattr(certificate.fields.serial, '__str__') else str(certificate.fields.serial)
# Extract serial from certificate — use the integer we assigned
# when ca_obj was provided, otherwise fall back to whatever the
# library generated.
if ca_obj is not None:
serial = str(assigned_serial)
else:
serial = str(certificate.fields.serial).split(":")[-1].strip() if hasattr(certificate.fields.serial, '__str__') else str(certificate.fields.serial)
# Build response
cert_string = certificate.to_string()
+41
View File
@@ -11,10 +11,51 @@ from gatehouse_app.extensions import bcrypt
logger = logging.getLogger(__name__)
# TOTP codes are valid for at most (2*window + 1) * 30s steps.
# With window=1 that's 3 steps = 90 seconds. We use a slightly
# generous TTL of 95 seconds to account for clock skew at boundaries.
_TOTP_USED_CODE_TTL = 95
class TOTPService:
"""Service for TOTP operations."""
# ------------------------------------------------------------------
# Replay-attack prevention helpers
# ------------------------------------------------------------------
@staticmethod
def _used_key(user_id: str, code: str) -> str:
return f"totp:used:{user_id}:{code}"
@staticmethod
def is_code_already_used(user_id: str, code: str) -> bool:
"""Return True if *code* has already been accepted for *user_id*
within the current validity window (prevents replay attacks)."""
try:
from gatehouse_app.extensions import redis_client
if redis_client is None:
return False
return redis_client.exists(TOTPService._used_key(user_id, code)) == 1
except Exception:
logger.warning("Redis unavailable for TOTP replay check; allowing code")
return False
@staticmethod
def mark_code_used(user_id: str, code: str) -> None:
"""Record *code* as consumed for *user_id* so it cannot be reused."""
try:
from gatehouse_app.extensions import redis_client
if redis_client is None:
return
redis_client.setex(
TOTPService._used_key(user_id, code),
_TOTP_USED_CODE_TTL,
"1",
)
except Exception:
logger.warning("Redis unavailable; TOTP used-code not recorded")
@staticmethod
def generate_secret() -> str:
"""
@@ -641,6 +641,26 @@ class WebAuthnService:
)
return True
@classmethod
def credential_belongs_to_user(cls, credential_id: str, user: User) -> bool:
"""Check whether *credential_id* exists and belongs to *user*.
Args:
credential_id: The credential ID to look up
user: User instance
Returns:
True if the credential exists and belongs to this user, False otherwise.
"""
auth_method = AuthenticationMethod.query.filter_by(
user_id=user.id,
method_type=AuthMethodType.WEBAUTHN,
deleted_at=None,
).first()
if not auth_method or not auth_method.provider_data:
return False
return auth_method.provider_data.get("credential_id") == credential_id
@classmethod
def rename_credential(cls, credential_id: str, user: User, name: str) -> bool: