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 @@
+
+
+
+
+
+
+ Multi-Factor Authentication
+
+
+
+ {{ statusMessage }}
+
+
+ Verify MFA
+
+
+
+
+
+
+
+
+
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",