enable policies

This commit is contained in:
2026-01-16 17:31:20 +10:30
parent b2e084db33
commit d063a0ca81
28 changed files with 4296 additions and 224 deletions
+18
View File
@@ -16,6 +16,7 @@ from gatehouse_app.services.oidc_service import (
OIDCService, InvalidClientError, InvalidGrantError, InvalidRequestError
)
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.extensions import db
from gatehouse_app.extensions import bcrypt as flask_bcrypt
from gatehouse_app.models import User, OIDCClient
@@ -372,6 +373,23 @@ def oidc_authorize():
logger.debug("[OIDC] Attempting user authentication for email: %s", email)
try:
user = AuthService.authenticate(email, password)
# Evaluate MFA policy after primary authentication
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
# Check if user can create full session
if not policy_result.can_create_full_session:
logger.debug("[OIDC] User cannot create full session due to MFA compliance: user_id=%s, email=%s", user.id, email)
return _show_login_page(
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
response_type=response_type,
error="Your account requires multi factor enrollment before using single sign on"
)
user_id = user.id
session["oidc_user_id"] = user_id
+1 -1
View File
@@ -5,4 +5,4 @@ from flask import Blueprint
api_v1_bp = Blueprint("api_v1", __name__)
# Import route modules to register them
from gatehouse_app.api.v1 import auth, users, organizations
from gatehouse_app.api.v1 import auth, users, organizations, policies
+133 -24
View File
@@ -22,6 +22,7 @@ from gatehouse_app.schemas.webauthn_schema import (
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.webauthn_service import WebAuthnService
from gatehouse_app.services.user_service import UserService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
@@ -94,6 +95,9 @@ def login():
400: Validation error
401: Invalid credentials
"""
import logging
logger = logging.getLogger(__name__)
try:
# Validate request data
schema = LoginSchema()
@@ -105,6 +109,11 @@ def login():
password=data["password"],
)
# SECURITY CHECK: Log MFA enrollment status to validate the vulnerability
has_totp = user.has_totp_enabled()
has_webauthn = user.has_webauthn_enabled()
logger.warning(f"[SECURITY DIAGNOSTIC] Login attempt for user {user.email} - TOTP enabled: {has_totp}, WebAuthn enabled: {has_webauthn}")
# Check if user has TOTP enabled for two-factor authentication
if user.has_totp_enabled():
# TOTP is enabled - store user_id in session for TOTP verification
@@ -121,16 +130,56 @@ def login():
)
# TOTP is NOT enabled - proceed with normal login flow
# SECURITY DIAGNOSTIC: This is where the vulnerability occurs - no WebAuthn check!
if has_webauthn:
logger.error(f"[SECURITY VULNERABILITY DETECTED] User {user.email} has WebAuthn enrolled but is bypassing it! Creating session without MFA verification.")
# Evaluate MFA policy after primary authentication
remember_me = data.get("remember_me", False)
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me)
# Create session with appropriate duration based on remember_me preference
duration = 2592000 if data.get("remember_me") else 86400 # 30 days vs 1 day
user_session = AuthService.create_session(user, duration_seconds=duration)
duration = 2592000 if remember_me else 86400 # 30 days vs 1 day
# Determine if this should be a compliance-only session
is_compliance_only = policy_result.create_compliance_only_session
user_session = AuthService.create_session(
user,
duration_seconds=duration,
is_compliance_only=is_compliance_only
)
# Build response data
response_data = {
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z" if user_session.expires_at.isoformat()[-1] != "Z" else user_session.expires_at.isoformat(),
}
# Add MFA compliance information
if policy_result.compliance_summary:
response_data["mfa_compliance"] = {
"overall_status": policy_result.compliance_summary.overall_status,
"missing_methods": policy_result.compliance_summary.missing_methods,
"deadline_at": policy_result.compliance_summary.deadline_at,
"orgs": [
{
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"deadline_at": org.deadline_at,
}
for org in policy_result.compliance_summary.orgs
],
}
# Add requires_mfa_enrollment flag if compliance-only session
if is_compliance_only:
response_data["requires_mfa_enrollment"] = True
return api_response(
data={
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z" if user_session.expires_at.isoformat()[-1] != "Z" else user_session.expires_at.isoformat(),
},
data=response_data,
message="Login successful",
)
@@ -380,20 +429,50 @@ def verify_totp():
client_utc_timestamp=data.get("client_timestamp"),
)
# Create full session
user_session = AuthService.create_session(user)
# Evaluate MFA policy after primary authentication
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
# Determine if this should be a compliance-only session
is_compliance_only = policy_result.create_compliance_only_session
# Create session
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
# Clear temporary session
session.pop("totp_pending_user_id", None)
# Build response data
response_data = {
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z"
if user_session.expires_at.isoformat()[-1] != "Z"
else user_session.expires_at.isoformat(),
}
# Add MFA compliance information
if policy_result.compliance_summary:
response_data["mfa_compliance"] = {
"overall_status": policy_result.compliance_summary.overall_status,
"missing_methods": policy_result.compliance_summary.missing_methods,
"deadline_at": policy_result.compliance_summary.deadline_at,
"orgs": [
{
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"deadline_at": org.deadline_at,
}
for org in policy_result.compliance_summary.orgs
],
}
# Add requires_mfa_enrollment flag if compliance-only session
if is_compliance_only:
response_data["requires_mfa_enrollment"] = True
return api_response(
data={
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z"
if user_session.expires_at.isoformat()[-1] != "Z"
else user_session.expires_at.isoformat(),
},
data=response_data,
message="TOTP verification successful",
)
@@ -835,22 +914,52 @@ def complete_webauthn_login():
challenge
)
# Evaluate MFA policy after primary authentication
policy_result = MfaPolicyService.after_primary_auth_success(user, remember_me=False)
# Determine if this should be a compliance-only session
is_compliance_only = policy_result.create_compliance_only_session
# Create session
user_session = AuthService.create_session(user)
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
# Clear pending session
session.pop("webauthn_pending_user_id", None)
logger.info(f"WebAuthn login completed successfully for user: {user.email}")
# Build response data
response_data = {
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z"
if user_session.expires_at.isoformat()[-1] != "Z"
else user_session.expires_at.isoformat(),
}
# Add MFA compliance information
if policy_result.compliance_summary:
response_data["mfa_compliance"] = {
"overall_status": policy_result.compliance_summary.overall_status,
"missing_methods": policy_result.compliance_summary.missing_methods,
"deadline_at": policy_result.compliance_summary.deadline_at,
"orgs": [
{
"organization_id": org.organization_id,
"organization_name": org.organization_name,
"status": org.status,
"deadline_at": org.deadline_at,
}
for org in policy_result.compliance_summary.orgs
],
}
# Add requires_mfa_enrollment flag if compliance-only session
if is_compliance_only:
response_data["requires_mfa_enrollment"] = True
return api_response(
data={
"user": user.to_dict(),
"token": user_session.token,
"expires_at": user_session.expires_at.isoformat() + "Z"
if user_session.expires_at.isoformat()[-1] != "Z"
else user_session.expires_at.isoformat(),
},
data=response_data,
message="Login successful",
)
+9 -1
View File
@@ -3,7 +3,7 @@ from flask import g, request
from marshmallow import ValidationError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required, require_admin, require_owner
from gatehouse_app.utils.decorators import login_required, require_admin, require_owner, full_access_required
from gatehouse_app.schemas.organization_schema import (
OrganizationCreateSchema,
OrganizationUpdateSchema,
@@ -17,6 +17,7 @@ from gatehouse_app.utils.constants import OrganizationRole
@api_v1_bp.route("/organizations", methods=["POST"])
@login_required
@full_access_required
def create_organization():
"""
Create a new organization.
@@ -65,6 +66,7 @@ def create_organization():
@api_v1_bp.route("/organizations/<org_id>", methods=["GET"])
@login_required
@full_access_required
def get_organization(org_id):
"""
Get organization by ID.
@@ -101,6 +103,7 @@ def get_organization(org_id):
@api_v1_bp.route("/organizations/<org_id>", methods=["PATCH"])
@login_required
@require_admin
@full_access_required
def update_organization(org_id):
"""
Update organization.
@@ -152,6 +155,7 @@ def update_organization(org_id):
@api_v1_bp.route("/organizations/<org_id>", methods=["DELETE"])
@login_required
@require_owner
@full_access_required
def delete_organization(org_id):
"""
Delete organization (soft delete).
@@ -180,6 +184,7 @@ def delete_organization(org_id):
@api_v1_bp.route("/organizations/<org_id>/members", methods=["GET"])
@login_required
@full_access_required
def get_organization_members(org_id):
"""
Get all members of an organization.
@@ -223,6 +228,7 @@ def get_organization_members(org_id):
@api_v1_bp.route("/organizations/<org_id>/members", methods=["POST"])
@login_required
@require_admin
@full_access_required
def add_organization_member(org_id):
"""
Add a member to the organization.
@@ -290,6 +296,7 @@ def add_organization_member(org_id):
@api_v1_bp.route("/organizations/<org_id>/members/<user_id>", methods=["DELETE"])
@login_required
@require_admin
@full_access_required
def remove_organization_member(org_id, user_id):
"""
Remove a member from the organization.
@@ -320,6 +327,7 @@ def remove_organization_member(org_id, user_id):
@api_v1_bp.route("/organizations/<org_id>/members/<user_id>/role", methods=["PATCH"])
@login_required
@require_admin
@full_access_required
def update_member_role(org_id, user_id):
"""
Update a member's role.
+336
View File
@@ -0,0 +1,336 @@
"""Security policy endpoints."""
from flask import g, request
from marshmallow import Schema, fields, validate, ValidationError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.services.organization_service import OrganizationService
from gatehouse_app.services.audit_service import AuditService
from gatehouse_app.utils.constants import MfaPolicyMode, MfaRequirementOverride, MfaComplianceStatus, AuditAction
class UpdateOrgPolicySchema(Schema):
"""Schema for updating organization security policy."""
mfa_policy_mode = fields.String(
required=False,
validate=validate.OneOf([m.value for m in MfaPolicyMode])
)
mfa_grace_period_days = fields.Integer(
required=False,
validate=validate.Range(min=0, max=365)
)
notify_days_before = fields.Integer(
required=False,
validate=validate.Range(min=0, max=30)
)
class UpdateUserPolicySchema(Schema):
"""Schema for updating user security policy override."""
mfa_override_mode = fields.String(
required=True,
validate=validate.OneOf([m.value for m in MfaRequirementOverride])
)
force_totp = fields.Boolean(required=False, load_default=False)
force_webauthn = fields.Boolean(required=False, load_default=False)
class ComplianceListQuerySchema(Schema):
"""Schema for compliance list query parameters."""
status = fields.String(required=False)
limit = fields.Integer(required=False, load_default=100)
offset = fields.Integer(required=False, load_default=0)
@api_v1_bp.route("/organizations/<org_id>/security-policy", methods=["GET"])
@login_required
def get_org_security_policy(org_id):
"""
Get organization security policy.
Args:
org_id: Organization ID
Returns:
200: Organization security policy
401: Not authenticated
403: Not a member
404: Organization not found
"""
org = OrganizationService.get_organization_by_id(org_id)
# Check if user is a member
if not org.is_member(g.current_user.id):
return api_response(
success=False,
message="You are not a member of this organization",
status=403,
error_type="AUTHORIZATION_ERROR",
)
policy_dto = MfaPolicyService.get_org_policy(org_id)
if policy_dto:
data = {
"organization_id": policy_dto.organization_id,
"mfa_policy_mode": policy_dto.mfa_policy_mode,
"mfa_grace_period_days": policy_dto.mfa_grace_period_days,
"notify_days_before": policy_dto.notify_days_before,
"policy_version": policy_dto.policy_version,
}
else:
# Return default policy if none exists
data = {
"organization_id": org_id,
"mfa_policy_mode": MfaPolicyMode.OPTIONAL.value,
"mfa_grace_period_days": 14,
"notify_days_before": 7,
"policy_version": 0,
}
return api_response(
data={"security_policy": data},
message="Security policy retrieved successfully",
)
@api_v1_bp.route("/organizations/<org_id>/security-policy", methods=["PUT"])
@login_required
@require_admin
@full_access_required
def update_org_security_policy(org_id):
"""
Update organization security policy.
Args:
org_id: Organization ID
Request body:
mfa_policy_mode: MFA policy mode (disabled, optional, require_totp, require_webauthn, require_totp_or_webauthn)
mfa_grace_period_days: Grace period in days (0-365)
notify_days_before: Days before deadline to notify (0-30)
Returns:
200: Security policy updated successfully
400: Validation error
401: Not authenticated
403: Not an admin
404: Organization not found
"""
try:
schema = UpdateOrgPolicySchema()
data = schema.load(request.json)
org = OrganizationService.get_organization_by_id(org_id)
# Update policy
policy = MfaPolicyService.create_org_policy(
organization_id=org_id,
mfa_policy_mode=MfaPolicyMode(data.get("mfa_policy_mode", MfaPolicyMode.OPTIONAL.value)),
mfa_grace_period_days=data.get("mfa_grace_period_days", 14),
notify_days_before=data.get("notify_days_before", 7),
updated_by_user_id=g.current_user.id,
)
return api_response(
data={
"security_policy": {
"organization_id": policy.organization_id,
"mfa_policy_mode": policy.mfa_policy_mode.value,
"mfa_grace_period_days": policy.mfa_grace_period_days,
"notify_days_before": policy.notify_days_before,
"policy_version": policy.policy_version,
}
},
message="Security policy updated successfully",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/organizations/<org_id>/mfa-compliance", methods=["GET"])
@login_required
@require_admin
@full_access_required
def get_org_mfa_compliance(org_id):
"""
Get MFA compliance list for an organization.
Args:
org_id: Organization ID
Query params:
status: Optional status filter (not_applicable, pending, in_grace, compliant, past_due, suspended)
limit: Maximum records to return (default 100)
offset: Offset for pagination (default 0)
Returns:
200: List of compliance records
401: Not authenticated
403: Not an admin
404: Organization not found
"""
org = OrganizationService.get_organization_by_id(org_id)
# Parse query params
status = None
if request.args.get("status"):
try:
status = MfaComplianceStatus(request.args.get("status"))
except ValueError:
return api_response(
success=False,
message="Invalid status value",
status=400,
error_type="VALIDATION_ERROR",
)
limit = min(int(request.args.get("limit", 100)), 100)
offset = int(request.args.get("offset", 0))
compliance_list = MfaPolicyService.get_org_compliance_list(
organization_id=org_id,
status=status,
limit=limit,
offset=offset,
)
return api_response(
data={
"compliance": compliance_list,
"count": len(compliance_list),
"limit": limit,
"offset": offset,
},
message="Compliance records retrieved successfully",
)
@api_v1_bp.route(
"/organizations/<org_id>/users/<user_id>/security-policy", methods=["PATCH"]
)
@login_required
@require_admin
@full_access_required
def update_user_security_policy(org_id, user_id):
"""
Update user security policy override.
Args:
org_id: Organization ID
user_id: User ID
Request body:
mfa_override_mode: Override mode (inherit, required, exempt)
force_totp: Force TOTP requirement (default false)
force_webauthn: Force WebAuthn requirement (default false)
Returns:
200: User policy updated successfully
400: Validation error
401: Not authenticated
403: Not an admin
404: Organization or user not found
"""
try:
schema = UpdateUserPolicySchema()
data = schema.load(request.json)
org = OrganizationService.get_organization_by_id(org_id)
# Check if user is a member of the organization
if not org.is_member(user_id):
return api_response(
success=False,
message="User is not a member of this organization",
status=404,
error_type="NOT_FOUND",
)
# Update user policy
policy = MfaPolicyService.set_user_override(
user_id=user_id,
organization_id=org_id,
mfa_override_mode=MfaRequirementOverride(data["mfa_override_mode"]),
force_totp=data.get("force_totp", False),
force_webauthn=data.get("force_webauthn", False),
updated_by_user_id=g.current_user.id,
)
# Log the override change with details
AuditService.log_action(
action=AuditAction.USER_SECURITY_POLICY_OVERRIDE_UPDATE,
user_id=g.current_user.id,
organization_id=org_id,
resource_type="user",
resource_id=user_id,
description=f"User security policy override changed to {data['mfa_override_mode']} for user {user_id}",
)
return api_response(
data={
"user_security_policy": {
"user_id": policy.user_id,
"organization_id": policy.organization_id,
"mfa_override_mode": policy.mfa_override_mode.value,
"force_totp": policy.force_totp,
"force_webauthn": policy.force_webauthn,
}
},
message="User security policy updated successfully",
)
except ValidationError as e:
return api_response(
success=False,
message="Validation failed",
status=400,
error_type="VALIDATION_ERROR",
error_details=e.messages,
)
@api_v1_bp.route("/users/me/mfa-compliance", methods=["GET"])
@login_required
def get_my_mfa_compliance():
"""
Get current user's MFA compliance across all organizations.
Returns:
200: MFA compliance summary
401: Not authenticated
"""
user = g.current_user
compliance_summary = MfaPolicyService.evaluate_user_mfa_state(user)
orgs = []
for org_state in compliance_summary.orgs:
orgs.append({
"organization_id": org_state.organization_id,
"organization_name": org_state.organization_name,
"status": org_state.status,
"effective_mode": org_state.effective_mode,
"deadline_at": org_state.deadline_at,
"applied_at": org_state.applied_at,
})
return api_response(
data={
"mfa_compliance": {
"overall_status": compliance_summary.overall_status,
"missing_methods": compliance_summary.missing_methods,
"deadline_at": compliance_summary.deadline_at,
"orgs": orgs,
}
},
message="MFA compliance retrieved successfully",
)
+5 -1
View File
@@ -3,7 +3,7 @@ from flask import g, request
from marshmallow import ValidationError
from gatehouse_app.api.v1 import api_v1_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.utils.decorators import login_required
from gatehouse_app.utils.decorators import login_required, full_access_required
from gatehouse_app.schemas.user_schema import UserUpdateSchema, ChangePasswordSchema
from gatehouse_app.services.user_service import UserService
from gatehouse_app.services.auth_service import AuthService
@@ -29,6 +29,7 @@ def get_me():
@api_v1_bp.route("/users/me", methods=["PATCH"])
@login_required
@full_access_required
def update_me():
"""
Update current user profile.
@@ -67,6 +68,7 @@ def update_me():
@api_v1_bp.route("/users/me", methods=["DELETE"])
@login_required
@full_access_required
def delete_me():
"""
Delete current user account (soft delete).
@@ -84,6 +86,7 @@ def delete_me():
@api_v1_bp.route("/users/me/password", methods=["POST"])
@login_required
@full_access_required
def change_password():
"""
Change current user password.
@@ -136,6 +139,7 @@ def change_password():
@api_v1_bp.route("/users/me/organizations", methods=["GET"])
@login_required
@full_access_required
def get_my_organizations():
"""
Get all organizations current user is a member of.