Fix(Feat): CA, Audits, Rte Limit
CA Encryption, Serials, Rate Limiter, Account suspension blocks login Transfer Ownership & Delete Account
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user