Feat(Chore): Verify Flow, Invites, Suspend, Depart Cert Policy
feat: add password reset and email verification flow feat: add org invite listing, cancellation, and invite link fallback feat: add user suspend/unsuspend with audit logging feat: add department certificate policy (expiry, extensions) feat: enforce dept cert policy on SSH certificate signing feat: wire up OIDC consent and token flow (replace mocks) feat: rework CLI auth bridge to use frontend login flow feat: add admin OAuth provider management (CRUD) chore: refactor model import paths after module reorganisation chore: clean up config, decorators, and dev tooling
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"""Audit service."""
|
||||
from flask import request, g
|
||||
from gatehouse_app.models.audit_log import AuditLog
|
||||
from gatehouse_app.models.auth.audit_log import AuditLog
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from flask import request, g, current_app
|
||||
from gatehouse_app.extensions import db, bcrypt
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.models.session import Session
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
|
||||
from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
|
||||
|
||||
@@ -8,7 +8,7 @@ from flask import current_app
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models import User, AuthenticationMethod
|
||||
from gatehouse_app.models.authentication_method import (
|
||||
from gatehouse_app.models.auth.authentication_method import (
|
||||
OAuthState,
|
||||
ApplicationProviderConfig,
|
||||
OrganizationProviderOverride
|
||||
@@ -1210,12 +1210,35 @@ class ExternalAuthService:
|
||||
else:
|
||||
email_verified = data.get("email_verified", False)
|
||||
|
||||
sub = data.get("sub")
|
||||
|
||||
# Derive email from sub when the provider omits the email claim.
|
||||
# This happens with some OIDC servers (including the nav-security mock)
|
||||
# that only return the minimal {sub, iss, iat, exp} set.
|
||||
# Rule: if sub looks like an email address, use it directly.
|
||||
# Otherwise, construct a deterministic fallback so we never get NULL.
|
||||
raw_email = data.get("email")
|
||||
if not raw_email and sub:
|
||||
import re as _re
|
||||
if _re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", sub):
|
||||
raw_email = sub
|
||||
email_verified = True # if sub IS the email it's already verified
|
||||
else:
|
||||
# e.g. "12345" → "12345@google.local" so we can store it
|
||||
raw_email = f"{sub}@{provider or 'oauth'}.local"
|
||||
email_verified = False
|
||||
|
||||
# Derive display name when omitted
|
||||
raw_name = data.get("name") or data.get("display_name")
|
||||
if not raw_name and raw_email:
|
||||
raw_name = raw_email.split("@")[0]
|
||||
|
||||
# Standardize user info
|
||||
return {
|
||||
"provider_user_id": data.get("sub"),
|
||||
"email": data.get("email"),
|
||||
"provider_user_id": sub,
|
||||
"email": raw_email,
|
||||
"email_verified": email_verified,
|
||||
"name": data.get("name"),
|
||||
"name": raw_name,
|
||||
"first_name": data.get("given_name"),
|
||||
"last_name": data.get("family_name"),
|
||||
"picture": data.get("picture"),
|
||||
|
||||
@@ -4,11 +4,11 @@ from datetime import datetime, timezone
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.organization_security_policy import OrganizationSecurityPolicy
|
||||
from gatehouse_app.models.user_security_policy import UserSecurityPolicy
|
||||
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy
|
||||
from gatehouse_app.models.security.user_security_policy import UserSecurityPolicy
|
||||
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.utils.constants import (
|
||||
MfaPolicyMode,
|
||||
@@ -702,7 +702,7 @@ class MfaPolicyService:
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
updated_count = 0
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ import logging
|
||||
import json
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
|
||||
from gatehouse_app.models.organization_security_policy import OrganizationSecurityPolicy
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
|
||||
from gatehouse_app.models.security.organization_security_policy import OrganizationSecurityPolicy
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
|
||||
@@ -37,6 +37,7 @@ class NotificationService:
|
||||
SMTP_PORT_KEY = "SMTP_PORT"
|
||||
SMTP_USERNAME_KEY = "SMTP_USERNAME"
|
||||
SMTP_PASSWORD_KEY = "SMTP_PASSWORD"
|
||||
SMTP_USE_TLS_KEY = "SMTP_USE_TLS"
|
||||
FROM_ADDRESS_KEY = "FROM_ADDRESS"
|
||||
|
||||
@staticmethod
|
||||
@@ -86,10 +87,9 @@ class NotificationService:
|
||||
if success:
|
||||
logger.info(
|
||||
f"Sent MFA deadline reminder to {user.email} "
|
||||
f"({days_until_deadline} days remaining # Audit log
|
||||
)"
|
||||
f"({days_until_deadline} days remaining)"
|
||||
)
|
||||
AuditService.log_action(
|
||||
AuditService.log_action(
|
||||
action=AuditAction.MFA_POLICY_USER_COMPLIANT,
|
||||
user_id=user.id,
|
||||
organization_id=compliance.organization_id,
|
||||
@@ -291,101 +291,62 @@ Gatehouse Security Team
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Send an email notification.
|
||||
"""Send an email via SMTP.
|
||||
|
||||
This method attempts to send an email using configured SMTP settings.
|
||||
If email is not configured, it logs the notification instead.
|
||||
|
||||
Args:
|
||||
to_address: Recipient email address
|
||||
subject: Email subject
|
||||
body: Plain text email body
|
||||
html_body: Optional HTML email body
|
||||
|
||||
Returns:
|
||||
True if email was sent (or logged), False on error
|
||||
Returns True if the email was sent successfully, False otherwise.
|
||||
If EMAIL_ENABLED is False, logs the email body instead (simulation mode).
|
||||
"""
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from flask import current_app
|
||||
|
||||
email_enabled = current_app.config.get(NotificationService.EMAIL_ENABLED_KEY, False)
|
||||
|
||||
if not email_enabled:
|
||||
logger.info(
|
||||
f"[EMAIL DISABLED] Would have sent to: {to_address} | Subject: {subject}\n"
|
||||
f"Body: {body[:500]}"
|
||||
)
|
||||
return False
|
||||
|
||||
smtp_host = current_app.config.get(NotificationService.SMTP_HOST_KEY, "localhost")
|
||||
smtp_port = int(current_app.config.get(NotificationService.SMTP_PORT_KEY, 587))
|
||||
smtp_username = current_app.config.get(NotificationService.SMTP_USERNAME_KEY)
|
||||
smtp_password = current_app.config.get(NotificationService.SMTP_PASSWORD_KEY)
|
||||
smtp_use_tls = current_app.config.get(
|
||||
NotificationService.SMTP_USE_TLS_KEY,
|
||||
smtp_port not in (25, 1025),
|
||||
)
|
||||
from_address = current_app.config.get(
|
||||
NotificationService.FROM_ADDRESS_KEY, "noreply@gatehouse.local"
|
||||
)
|
||||
|
||||
try:
|
||||
from flask import current_app
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = from_address
|
||||
msg["To"] = to_address
|
||||
msg.attach(MIMEText(body, "plain"))
|
||||
if html_body:
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
|
||||
# Check if email is configured
|
||||
email_enabled = current_app.config.get(
|
||||
NotificationService.EMAIL_ENABLED_KEY, False
|
||||
)
|
||||
|
||||
if not email_enabled:
|
||||
# Log the notification instead of sending
|
||||
logger.info(
|
||||
f"[EMAIL SIMULATION] To: {to_address}\n"
|
||||
f"Subject: {subject}\n"
|
||||
f"Body: {body[:200]}..." if len(body) > 200 else f"Body: {body}"
|
||||
)
|
||||
return True
|
||||
|
||||
# Get email configuration
|
||||
smtp_host = current_app.config.get(NotificationService.SMTP_HOST_KEY)
|
||||
smtp_port = current_app.config.get(NotificationService.SMTP_PORT_KEY, 587)
|
||||
smtp_username = current_app.config.get(NotificationService.SMTP_USERNAME_KEY)
|
||||
smtp_password = current_app.config.get(NotificationService.SMTP_PASSWORD_KEY)
|
||||
from_address = current_app.config.get(
|
||||
NotificationService.FROM_ADDRESS_KEY, "noreply@gatehouse.local"
|
||||
)
|
||||
|
||||
# Import send_email based on available mail library
|
||||
try:
|
||||
from flask_mail import Message
|
||||
|
||||
from gatehouse_app import mail
|
||||
|
||||
msg = Message(
|
||||
subject=subject,
|
||||
recipients=[to_address],
|
||||
body=body,
|
||||
html=html_body,
|
||||
sender=from_address,
|
||||
)
|
||||
mail.send(msg)
|
||||
logger.info(f"Email sent successfully to {to_address}")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
# Flask-Mail not available, use SMTP directly
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = from_address
|
||||
msg["To"] = to_address
|
||||
|
||||
# Attach plain text and HTML versions
|
||||
part1 = MIMEText(body, "plain")
|
||||
msg.attach(part1)
|
||||
|
||||
if html_body:
|
||||
part2 = MIMEText(html_body, "html")
|
||||
msg.attach(part2)
|
||||
|
||||
# Send via SMTP
|
||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
||||
server.ehlo()
|
||||
if smtp_use_tls:
|
||||
server.starttls()
|
||||
if smtp_username and smtp_password:
|
||||
server.login(smtp_username, smtp_password)
|
||||
server.send_message(msg)
|
||||
server.ehlo()
|
||||
if smtp_username and smtp_password:
|
||||
server.login(smtp_username, smtp_password)
|
||||
server.send_message(msg)
|
||||
|
||||
logger.info(f"Email sent successfully to {to_address}")
|
||||
return True
|
||||
logger.info(f"[EMAIL] Sent to {to_address} | Subject: {subject}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to send email to {to_address}: {e}")
|
||||
# Log the notification as fallback
|
||||
logger.info(
|
||||
f"[EMAIL FALLBACK] To: {to_address}\n"
|
||||
f"Subject: {subject}\n"
|
||||
f"Body: {body[:500]}..." if len(body) > 500 else f"Body: {body}"
|
||||
)
|
||||
return True # Return True to continue processing
|
||||
logger.error(f"[EMAIL] Failed to send to {to_address}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_notification_stats(user_id: str) -> Dict[str, Any]:
|
||||
@@ -397,7 +358,7 @@ Gatehouse Security Team
|
||||
Returns:
|
||||
Dictionary with notification statistics
|
||||
"""
|
||||
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
|
||||
from gatehouse_app.models.security.mfa_policy_compliance import MfaPolicyCompliance
|
||||
|
||||
stats = {
|
||||
"total_notifications": 0,
|
||||
|
||||
@@ -9,9 +9,9 @@ from flask import current_app, request, g, redirect
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models import User, AuthenticationMethod
|
||||
from gatehouse_app.models.authentication_method import OAuthState
|
||||
from gatehouse_app.models.auth.authentication_method import OAuthState
|
||||
from gatehouse_app.models.base import BaseModel
|
||||
from gatehouse_app.models.oidc_authorization_code import OIDCAuthCode
|
||||
from gatehouse_app.models.oidc.oidc_authorization_code import OIDCAuthCode
|
||||
from gatehouse_app.utils.constants import AuthMethodType, AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
from gatehouse_app.services.external_auth_service import (
|
||||
@@ -776,7 +776,7 @@ class OAuthFlowService:
|
||||
|
||||
# If organization_id hint was provided and valid, create session for that org
|
||||
if state_record.organization_id:
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
org = Organization.query.get(state_record.organization_id)
|
||||
if org:
|
||||
from gatehouse_app.services.auth_service import AuthService
|
||||
@@ -987,8 +987,8 @@ class OAuthFlowService:
|
||||
)
|
||||
|
||||
# Determine organization
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
|
||||
# Get user's organizations
|
||||
user_orgs = user.get_organizations()
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Dict, List, Optional, Tuple
|
||||
from flask import current_app
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.oidc_jwks_key import OidcJwksKey
|
||||
from gatehouse_app.models.oidc.oidc_jwks_key import OidcJwksKey
|
||||
|
||||
|
||||
class JWKSKey:
|
||||
|
||||
@@ -14,7 +14,7 @@ from gatehouse_app.models import (
|
||||
User, OIDCClient, OIDCAuthCode, OIDCRefreshToken,
|
||||
OIDCSession, OIDCTokenMetadata
|
||||
)
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.exceptions.validation_exceptions import (
|
||||
ValidationError, NotFoundError, BadRequestError
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import jwt
|
||||
from flask import current_app, g
|
||||
|
||||
from gatehouse_app.models import User, OIDCClient
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.services.oidc_jwks_service import OIDCJWKSService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -3,8 +3,8 @@ import logging
|
||||
from datetime import datetime, timezone
|
||||
from flask import current_app
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.organization import Organization
|
||||
from gatehouse_app.models.organization_member import OrganizationMember
|
||||
from gatehouse_app.models.organization.organization import Organization
|
||||
from gatehouse_app.models.organization.organization_member import OrganizationMember
|
||||
from gatehouse_app.exceptions.validation_exceptions import OrganizationNotFoundError, ConflictError
|
||||
from gatehouse_app.utils.constants import OrganizationRole, AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Session service."""
|
||||
from datetime import datetime, timezone
|
||||
from gatehouse_app.models.session import Session
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.utils.constants import SessionStatus
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class SessionService:
|
||||
Returns:
|
||||
Session object if found and active, None otherwise
|
||||
"""
|
||||
from gatehouse_app.models.session import Session
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.utils.constants import SessionStatus
|
||||
return Session.query.filter_by(
|
||||
token=token,
|
||||
|
||||
@@ -253,12 +253,13 @@ class SSHCASigningService:
|
||||
certificate.fields.valid_after = now
|
||||
certificate.fields.valid_before = valid_before
|
||||
|
||||
# Set extensions
|
||||
# Set extensions — prefer policy-provided list, fall back to standard set
|
||||
extensions = signing_request.extensions
|
||||
if not extensions and self.config.get_bool('extensions_enabled'):
|
||||
extensions = self.config.get_list('extensions')
|
||||
|
||||
certificate.fields.extensions = extensions or []
|
||||
if not extensions:
|
||||
from gatehouse_app.models.organization.department_cert_policy import STANDARD_EXTENSIONS
|
||||
extensions = list(STANDARD_EXTENSIONS)
|
||||
|
||||
certificate.fields.extensions = extensions
|
||||
certificate.fields.critical_options = signing_request.critical_options or {}
|
||||
|
||||
# Validate certificate before signing
|
||||
|
||||
@@ -291,6 +291,8 @@ class SSHKeyService:
|
||||
logger.info(f"SSH key verified: {key_id}")
|
||||
return True
|
||||
|
||||
except SSHKeyError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"SSH key verification failed: {str(e)}")
|
||||
raise SSHKeyError(f"Signature verification failed: {str(e)}")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import logging
|
||||
from flask import current_app
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.exceptions.validation_exceptions import UserNotFoundError
|
||||
from gatehouse_app.utils.constants import AuditAction
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
@@ -10,8 +10,8 @@ from flask import current_app
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from gatehouse_app.extensions import db, redis_client
|
||||
from gatehouse_app.models.user import User
|
||||
from gatehouse_app.models.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.utils.constants import AuthMethodType, AuditAction
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
|
||||
Reference in New Issue
Block a user