diff --git a/setup.py b/setup.py index ef838342801a..169f24975aa8 100644 --- a/setup.py +++ b/setup.py @@ -311,9 +311,7 @@ def _build_static(self): env["DISPATCH_STATIC_DIST_PATH"] = self.dispatch_static_dist_path env["NODE_ENV"] = "production" # TODO: Our JS builds should not require 4GB heap space - env["NODE_OPTIONS"] = ( - (env.get("NODE_OPTIONS", "") + " --max-old-space-size=4096") - ).lstrip() + env["NODE_OPTIONS"] = (env.get("NODE_OPTIONS", "") + " --max-old-space-size=4096").lstrip() # self._run_npm_command(["webpack", "--bail"], env=env) def _write_version_file(self, version_info): @@ -405,6 +403,7 @@ def run(self): "dispatch_atlassian_confluence = dispatch.plugins.dispatch_atlassian_confluence.plugin:ConfluencePagePlugin", "dispatch_atlassian_confluence_document = dispatch.plugins.dispatch_atlassian_confluence.docs.plugin:ConfluencePageDocPlugin", "dispatch_aws_sqs = dispatch.plugins.dispatch_aws.plugin:AWSSQSSignalConsumerPlugin", + "dispatch_auth_mfa = dispatch.plugins.dispatch_core.plugin:DispatchMfaPlugin", "dispatch_basic_auth = dispatch.plugins.dispatch_core.plugin:BasicAuthProviderPlugin", "dispatch_contact = dispatch.plugins.dispatch_core.plugin:DispatchContactPlugin", "dispatch_document_resolver = dispatch.plugins.dispatch_core.plugin:DispatchDocumentResolverPlugin", diff --git a/src/dispatch/auth/models.py b/src/dispatch/auth/models.py index 56827f9ca588..e430a3df8665 100644 --- a/src/dispatch/auth/models.py +++ b/src/dispatch/auth/models.py @@ -2,6 +2,7 @@ import secrets from typing import List from datetime import datetime, timedelta +from uuid import uuid4 import bcrypt from jose import jwt @@ -10,6 +11,7 @@ from pydantic.networks import EmailStr from sqlalchemy import DateTime, Column, String, LargeBinary, Integer, Boolean +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from sqlalchemy.sql.schema import ForeignKey from sqlalchemy_utils import TSVectorType @@ -20,7 +22,7 @@ DISPATCH_JWT_EXP, ) from dispatch.database.core import Base -from dispatch.enums import UserRoles +from dispatch.enums import DispatchEnum, UserRoles from dispatch.models import OrganizationSlug, PrimaryKey, TimeStampMixin, DispatchBase, Pagination from dispatch.organization.models import Organization, OrganizationRead from dispatch.project.models import Project, ProjectRead @@ -192,3 +194,31 @@ class UserRegisterResponse(DispatchBase): class UserPagination(Pagination): items: List[UserRead] = [] + + +class MfaChallengeStatus(DispatchEnum): + PENDING = "pending" + APPROVED = "approved" + DENIED = "denied" + EXPIRED = "expired" + + +class MfaChallenge(Base, TimeStampMixin): + id = Column(Integer, primary_key=True, autoincrement=True) + valid = Column(Boolean, default=False) + reason = Column(String, nullable=True) + action = Column(String) + status = Column(String, default=MfaChallengeStatus.PENDING) + challenge_id = Column(UUID(as_uuid=True), default=uuid4, unique=True) + dispatch_user_id = Column(Integer, ForeignKey(DispatchUser.id), nullable=False) + dispatch_user = relationship(DispatchUser, backref="mfa_challenges") + + +class MfaPayloadResponse(DispatchBase): + status: str + + +class MfaPayload(DispatchBase): + action: str + project_id: int + challenge_id: str diff --git a/src/dispatch/auth/views.py b/src/dispatch/auth/views.py index cf3059f3c4b5..4b4d1c3bdaed 100644 --- a/src/dispatch/auth/views.py +++ b/src/dispatch/auth/views.py @@ -1,3 +1,5 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException, status from pydantic.error_wrappers import ErrorWrapper, ValidationError @@ -17,9 +19,13 @@ from dispatch.database.service import CommonParameters, search_filter_sort_paginate from dispatch.enums import UserRoles from dispatch.models import OrganizationSlug, PrimaryKey +from dispatch.plugin import service as plugin_service +from dispatch.plugins.dispatch_core.exceptions import MfaException from dispatch.organization.models import OrganizationRead from .models import ( + MfaPayload, + MfaPayloadResponse, UserLogin, UserLoginResponse, UserOrganization, @@ -33,6 +39,8 @@ from .service import get, get_by_email, update, create +log = logging.getLogger(__name__) + auth_router = APIRouter() user_router = APIRouter() @@ -246,6 +254,49 @@ def register_user( return user +@auth_router.post("/mfa", response_model=MfaPayloadResponse) +def mfa_check( + payload_in: MfaPayload, + current_user: CurrentUser, + db_session: DbSession, +): + log.info(f"MFA check initiated for user: {current_user.email}") + log.debug(f"Payload received: {payload_in.dict()}") + + try: + log.info(f"Attempting to get active MFA plugin for project: {payload_in.project_id}") + mfa_auth_plugin = plugin_service.get_active_instance( + db_session=db_session, project_id=payload_in.project_id, plugin_type="auth-mfa" + ) + + if not mfa_auth_plugin: + log.error(f"MFA plugin not enabled for project: {payload_in.project_id}") + raise HTTPException( + status_code=400, detail="MFA plugin is not enabled for the project." + ) + + log.info(f"MFA plugin found: {mfa_auth_plugin.__class__.__name__}") + + log.info("Validating MFA token") + status = mfa_auth_plugin.instance.validate_mfa_token(payload_in, current_user, db_session) + + log.info("MFA token validation successful") + return MfaPayloadResponse(status=status) + + except MfaException as e: + log.error(f"MFA Exception occurred: {str(e)}") + log.debug(f"MFA Exception details: {type(e).__name__}", exc_info=True) + raise HTTPException(status_code=400, detail=str(e)) from e + + except Exception as e: + log.critical(f"Unexpected error in MFA check: {str(e)}") + log.exception("Full traceback:") + raise HTTPException(status_code=500, detail="An unexpected error occurred") from e + + finally: + log.info("MFA check completed") + + if DISPATCH_AUTH_REGISTRATION_ENABLED: register_user = auth_router.post("/register", response_model=UserRegisterResponse)( register_user diff --git a/src/dispatch/database/revisions/tenant/versions/2024-09-06_51eacaf1f62c.py b/src/dispatch/database/revisions/tenant/versions/2024-09-06_51eacaf1f62c.py new file mode 100644 index 000000000000..2457593fc369 --- /dev/null +++ b/src/dispatch/database/revisions/tenant/versions/2024-09-06_51eacaf1f62c.py @@ -0,0 +1,45 @@ +"""Adds mfa_challenge to track challenges against core Dispatch MFA plugin. + +Revision ID: 51eacaf1f62c +Revises: 71cd7ed999c4 +Create Date: 2024-08-09 12:59:54.631968 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "51eacaf1f62c" +down_revision = "d6b3853be8e4" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "mfa_challenge", + sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), + sa.Column("valid", sa.Boolean(), nullable=True, server_default=sa.text("true")), + sa.Column("reason", sa.String(), nullable=True), + sa.Column("action", sa.String(), nullable=True), + sa.Column("challenge_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("dispatch_user_id", sa.Integer(), nullable=False), + sa.Column("status", sa.String(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["dispatch_user_id"], + ["dispatch_core.dispatch_user.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("challenge_id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("mfa_challenge") + # ### end Alembic commands ### diff --git a/src/dispatch/plugins/bases/auth_mfa.py b/src/dispatch/plugins/bases/auth_mfa.py index c12eff34aa8e..d39cbc4fea16 100644 --- a/src/dispatch/plugins/bases/auth_mfa.py +++ b/src/dispatch/plugins/bases/auth_mfa.py @@ -13,3 +13,9 @@ class MultiFactorAuthenticationPlugin(Plugin): def send_push_notification(self, items, **kwargs): raise NotImplementedError + + def validate_mfa(self, items, **kwargs): + raise NotImplementedError + + def create_mfa_challenge(self, items, **kwargs): + raise NotImplementedError diff --git a/src/dispatch/plugins/dispatch_core/exceptions.py b/src/dispatch/plugins/dispatch_core/exceptions.py new file mode 100644 index 000000000000..cefa6f51672b --- /dev/null +++ b/src/dispatch/plugins/dispatch_core/exceptions.py @@ -0,0 +1,34 @@ +class MfaException(Exception): + """Base exception for MFA-related errors.""" + + pass + + +class InvalidChallengeError(MfaException): + """Raised when the challenge is invalid.""" + + pass + + +class UserMismatchError(MfaException): + """Raised when the challenge doesn't belong to the current user.""" + + pass + + +class ActionMismatchError(MfaException): + """Raised when the action doesn't match the challenge.""" + + pass + + +class ExpiredChallengeError(MfaException): + """Raised when the challenge is no longer valid.""" + + pass + + +class InvalidChallengeStateError(MfaException): + """Raised when the challenge is in an invalid state.""" + + pass diff --git a/src/dispatch/plugins/dispatch_core/plugin.py b/src/dispatch/plugins/dispatch_core/plugin.py index 39ccebdb9c43..1a6d0e73e99d 100644 --- a/src/dispatch/plugins/dispatch_core/plugin.py +++ b/src/dispatch/plugins/dispatch_core/plugin.py @@ -4,9 +4,13 @@ :copyright: (c) 2019 by Netflix Inc., see AUTHORS for more :license: Apache, see LICENSE for more details. """ + import base64 import json import logging +import time +from typing import Literal +from uuid import UUID import requests from fastapi import HTTPException @@ -15,7 +19,9 @@ from jose.exceptions import JWKError from starlette.requests import Request from starlette.status import HTTP_401_UNAUTHORIZED +from sqlalchemy.orm import Session +from dispatch.auth.models import MfaChallenge, MfaPayload, DispatchUser, MfaChallengeStatus from dispatch.case import service as case_service from dispatch.config import ( DISPATCH_AUTHENTICATION_PROVIDER_HEADER_NAME, @@ -38,9 +44,18 @@ AuthenticationProviderPlugin, ContactPlugin, DocumentResolverPlugin, + MultiFactorAuthenticationPlugin, ParticipantPlugin, TicketPlugin, ) +from dispatch.plugins.dispatch_core.exceptions import ( + InvalidChallengeError, + UserMismatchError, + ActionMismatchError, + ExpiredChallengeError, + InvalidChallengeStateError, +) +from dispatch.project import service as project_service from dispatch.route import service as route_service from dispatch.service import service as service_service from dispatch.service.models import Service, ServiceRead @@ -273,6 +288,115 @@ def get( return recommendation.matches +class DispatchMfaPlugin(MultiFactorAuthenticationPlugin): + title = "Dispatch Plugin - Multi Factor Authentication" + slug = "dispatch-auth-mfa" + description = "Uses dispatch itself to validate external requests." + version = dispatch_plugin.__version__ + + author = "Netflix" + author_url = "https://github.com/netflix/dispatch.git" + + def wait_for_challenge( + self, + challenge_id: UUID, + db_session: Session, + timeout: int = 300, + ) -> MfaChallengeStatus: + """Waits for a multi-factor authentication challenge.""" + start_time = time.time() + + while time.time() - start_time < timeout: + db_session.expire_all() + challenge = db_session.query(MfaChallenge).filter_by(challenge_id=challenge_id).first() + + if not challenge: + log.error(f"Challenge not found: {challenge_id}") + raise Exception("Challenge not found.") + + if challenge.status == MfaChallengeStatus.APPROVED: + return MfaChallengeStatus.APPROVED + elif challenge.status == MfaChallengeStatus.DENIED: + raise Exception("Challenge denied.") + + time.sleep(1) + + # Timeout reached + log.warning(f"Timeout reached for challenge: {challenge_id}") + + # Update the challenge status to EXPIRED if it times out + challenge = db_session.query(MfaChallenge).filter_by(challenge_id=challenge_id).first() + if challenge: + log.info(f"Updating challenge {challenge_id} status to EXPIRED") + challenge.status = MfaChallengeStatus.EXPIRED + db_session.commit() + else: + log.error(f"Challenge not found when trying to expire: {challenge_id}") + + return MfaChallengeStatus.EXPIRED + + def create_mfa_challenge( + self, + action: str, + current_user: DispatchUser, + db_session: Session, + project_id: int, + ) -> tuple[MfaChallenge, str]: + """Creates a multi-factor authentication challenge.""" + project = project_service.get(db_session=db_session, project_id=project_id) + + challenge = MfaChallenge( + action=action, + dispatch_user_id=current_user.id, + valid=True, + ) + db_session.add(challenge) + db_session.commit() + + org_slug = project.organization.slug if project.organization else "default" + + challenge_url = f"{DISPATCH_UI_URL}/{org_slug}/mfa?project_id={project_id}&challenge_id={challenge.challenge_id}&action={action}" + return challenge, challenge_url + + def validate_mfa_token( + self, + payload: MfaPayload, + current_user: DispatchUser, + db_session: Session, + ) -> Literal[MfaChallengeStatus.APPROVED]: + """Validates a multi-factor authentication token.""" + challenge: MfaChallenge | None = ( + db_session.query(MfaChallenge) + .filter_by(challenge_id=payload.challenge_id) + .one_or_none() + ) + + if not challenge: + raise InvalidChallengeError("Invalid challenge ID") + if challenge.dispatch_user_id != current_user.id: + raise UserMismatchError(f"Challenge does not belong to the current user: {current_user.email}") + if challenge.action != payload.action: + raise ActionMismatchError("Action mismatch") + if not challenge.valid: + raise ExpiredChallengeError("Challenge is no longer valid") + if challenge.status != MfaChallengeStatus.PENDING: + raise InvalidChallengeStateError(f"Challenge is in invalid state: {challenge.status}") + + challenge.status = MfaChallengeStatus.APPROVED + db_session.add(challenge) + db_session.commit() + + return challenge.status + + def send_push_notification(self, items, **kwargs): + # Implement this method if needed + raise NotImplementedError + + def validate_mfa(self, items, **kwargs): + # Implement this method if needed + raise NotImplementedError + + class DispatchContactPlugin(ContactPlugin): title = "Dispatch Plugin - Contact plugin" slug = "dispatch-contact" diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py index 1f57da70fa5b..8b2ff019cac0 100644 --- a/src/dispatch/plugins/dispatch_slack/case/interactive.py +++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py @@ -25,7 +25,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from dispatch.auth.models import DispatchUser +from dispatch.auth.models import DispatchUser, MfaChallengeStatus from dispatch.case import flows as case_flows from dispatch.case import service as case_service from dispatch.case.enums import CaseStatus, CaseResolutionReason @@ -1814,7 +1814,7 @@ def engagement_button_approve_click( Context( elements=[ MarkdownText( - text="After submission, you will be asked to confirm a Multi-Factor Authentication (MFA) prompt, please have your MFA device ready." + text="💡 After submission, you will be asked to validate your identity by completing a Multi-Factor Authentication challenge." ) ] ), @@ -1831,18 +1831,37 @@ def engagement_button_approve_click( client.views_open(trigger_id=body["trigger_id"], view=modal) -def ack_engagement_submission_event(ack: Ack, mfa_enabled: bool) -> None: +def ack_engagement_submission_event( + ack: Ack, mfa_enabled: bool, challenge_url: str | None = None +) -> None: """Handles the add engagement submission event acknowledgement.""" - text = ( - "Confirming suspicious event..." - if mfa_enabled is False - else "Sending MFA push notification, please confirm to create Engagement filter..." - ) + + if mfa_enabled: + mfa_text = ( + "🔐 To complete this action, you need to verify your identity through Multi-Factor Authentication (MFA).\n\n" + f"Please <{challenge_url}|click here> to open the MFA verification page." + ) + else: + mfa_text = "✅ No additional verification required. You can proceed with the confirmation." + + blocks = [ + Section(text=mfa_text), + Divider(), + Context( + elements=[ + MarkdownText( + text="💡 This step protects against unauthorized confirmation if your account is compromised." + ) + ] + ), + ] + modal = Modal( - title="Confirm", - close="Close", - blocks=[Section(text=text)], + title="Confirm Your Identity", + close="Cancel", + blocks=blocks, ).build() + ack(response_action="update", view=modal) @@ -1878,41 +1897,30 @@ def handle_engagement_submission_event( db_session=db_session, project_id=context["subject"].project_id, plugin_type="auth-mfa" ) mfa_enabled = True if mfa_plugin and engagement.require_mfa else False + challenge, challenge_url = mfa_plugin.instance.create_mfa_challenge( + action="signal-engagement-confirmation", + current_user=user, + db_session=db_session, + project_id=context["subject"].project_id, + ) - ack_engagement_submission_event(ack=ack, mfa_enabled=mfa_enabled) + ack_engagement_submission_event(ack=ack, mfa_enabled=mfa_enabled, challenge_url=challenge_url) case = case_service.get(db_session=db_session, case_id=metadata["id"]) signal_instance = signal_service.get_signal_instance( db_session=db_session, signal_instance_id=UUID(metadata["signal_instance_id"]) ) - # Get context provided by the user context_from_user = body["view"]["state"]["values"][DefaultBlockIds.description_input][ DefaultBlockIds.description_input ]["value"] - # Check if last_mfa_time was within the last hour - last_hour = datetime.now() - timedelta(hours=1) - if (user.last_mfa_time and user.last_mfa_time > last_hour) or mfa_enabled is False: - return send_engagement_response( - case=case, - client=client, - context_from_user=context_from_user, - db_session=db_session, - engagement=engagement, - engaged_user=engaged_user, - response=PushResponseResult.allow, - signal_instance=signal_instance, - user=user_who_clicked_button, - view_id=body["view"]["id"], - ) - - # Send the MFA push notification - response = mfa_plugin.instance.send_push_notification( - username=engaged_user, - type="Are you confirming the behavior as expected in Dispatch?", + # wait for the mfa challenge + response = mfa_plugin.instance.wait_for_challenge( + challenge_id=challenge.challenge_id, + db_session=db_session, ) - if response == PushResponseResult.allow: + if response == MfaChallengeStatus.APPROVED: send_engagement_response( case=case, client=client, @@ -1925,7 +1933,6 @@ def handle_engagement_submission_event( user=user_who_clicked_button, view_id=body["view"]["id"], ) - user.last_mfa_time = datetime.now() db_session.commit() return else: @@ -1955,7 +1962,7 @@ def send_engagement_response( user: DispatchUser, view_id: str, ): - if response == PushResponseResult.allow: + if response == MfaChallengeStatus.APPROVED: title = "Approve" text = "Confirmation... Success!" message_text = f":white_check_mark: {engaged_user} confirmed the behavior *is expected*.\n\n *Context Provided* \n```{context_from_user}```" @@ -1964,14 +1971,14 @@ def send_engagement_response( title = "MFA Failed" engagement_status = SignalEngagementStatus.denied - if response == PushResponseResult.timeout: + if response == MfaChallengeStatus.EXPIRED: text = "Confirmation failed, the MFA request timed out. Please have your MFA device ready to accept the push notification and try again." - elif response == PushResponseResult.user_not_found: + elif response == MfaChallengeStatus.DENIED: text = "User not found in MFA provider. To validate your identity, please register in Duo and try again." else: text = "Confirmation failed. You must accept the MFA prompt." - message_text = f":warning: {engaged_user} attempted to confirm the behavior *as expected*, but the MFA validation failed.\n\n *Error Reason**: `{response}`\n\n{text}\n\n *Context Provided* \n```{context_from_user}```\n\n" + message_text = f":warning: {engaged_user} attempted to confirm the behavior *as expected*, but the MFA validation failed.\n\n **Error Reason**: `{response}`\n\n{text}\n\n *Context Provided* \n```{context_from_user}```\n\n" send_success_modal( client=client, @@ -1985,7 +1992,7 @@ def send_engagement_response( thread_ts=case.conversation.thread_id, ) - if response == PushResponseResult.allow: + if response == MfaChallengeStatus.APPROVED: # We only update engagement message (which removes Confirm/Deny button) for success # this allows the user to retry the confirmation if the MFA check failed blocks = create_signal_engagement_message( diff --git a/src/dispatch/static/dispatch/src/auth/Mfa.vue b/src/dispatch/static/dispatch/src/auth/Mfa.vue new file mode 100644 index 000000000000..c7fd6fc236dd --- /dev/null +++ b/src/dispatch/static/dispatch/src/auth/Mfa.vue @@ -0,0 +1,106 @@ + + + diff --git a/src/dispatch/static/dispatch/src/auth/api.js b/src/dispatch/static/dispatch/src/auth/api.js index 73c5cb7f5ef6..dc81d9f9b0df 100644 --- a/src/dispatch/static/dispatch/src/auth/api.js +++ b/src/dispatch/static/dispatch/src/auth/api.js @@ -27,4 +27,7 @@ export default { register(email, password) { return API.post(`/auth/register`, { email: email, password: password }) }, + verifyMfa(payload) { + return API.post(`/auth/mfa`, payload) + }, } diff --git a/src/dispatch/static/dispatch/src/router/config.js b/src/dispatch/static/dispatch/src/router/config.js index 6ea70af4239b..613214b6ce86 100644 --- a/src/dispatch/static/dispatch/src/router/config.js +++ b/src/dispatch/static/dispatch/src/router/config.js @@ -67,6 +67,12 @@ export const protectedRoute = [ }, }, ...withPrefix("/:organization/", [ + { + path: "mfa", + name: "mfa", + meta: { title: "Dispatch Mfa", requiresAuth: true }, + component: () => import("@/auth/Mfa.vue"), + }, { path: "incidents/status", name: "status",