Skip to content

Commit b0a4721

Browse files
wssheldonmvilanova
andauthored
feat(mfa/plugin): add dispatch mfa plugin (Netflix#5175)
* feat(mfa/plugin): add dispatch mfa plugin * feat(mfa/plugin): add dispatch mfa plugin * feat(mfa/plugin): add dispatch mfa plugin * feat: improve confirmation modal * Update src/dispatch/plugins/dispatch_core/plugin.py Co-authored-by: Marc Vilanova <[email protected]> --------- Co-authored-by: Marc Vilanova <[email protected]>
1 parent 2598230 commit b0a4721

File tree

11 files changed

+455
-44
lines changed

11 files changed

+455
-44
lines changed

setup.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -311,9 +311,7 @@ def _build_static(self):
311311
env["DISPATCH_STATIC_DIST_PATH"] = self.dispatch_static_dist_path
312312
env["NODE_ENV"] = "production"
313313
# TODO: Our JS builds should not require 4GB heap space
314-
env["NODE_OPTIONS"] = (
315-
(env.get("NODE_OPTIONS", "") + " --max-old-space-size=4096")
316-
).lstrip()
314+
env["NODE_OPTIONS"] = (env.get("NODE_OPTIONS", "") + " --max-old-space-size=4096").lstrip()
317315
# self._run_npm_command(["webpack", "--bail"], env=env)
318316

319317
def _write_version_file(self, version_info):
@@ -405,6 +403,7 @@ def run(self):
405403
"dispatch_atlassian_confluence = dispatch.plugins.dispatch_atlassian_confluence.plugin:ConfluencePagePlugin",
406404
"dispatch_atlassian_confluence_document = dispatch.plugins.dispatch_atlassian_confluence.docs.plugin:ConfluencePageDocPlugin",
407405
"dispatch_aws_sqs = dispatch.plugins.dispatch_aws.plugin:AWSSQSSignalConsumerPlugin",
406+
"dispatch_auth_mfa = dispatch.plugins.dispatch_core.plugin:DispatchMfaPlugin",
408407
"dispatch_basic_auth = dispatch.plugins.dispatch_core.plugin:BasicAuthProviderPlugin",
409408
"dispatch_contact = dispatch.plugins.dispatch_core.plugin:DispatchContactPlugin",
410409
"dispatch_document_resolver = dispatch.plugins.dispatch_core.plugin:DispatchDocumentResolverPlugin",

src/dispatch/auth/models.py

+31-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import secrets
33
from typing import List
44
from datetime import datetime, timedelta
5+
from uuid import uuid4
56

67
import bcrypt
78
from jose import jwt
@@ -10,6 +11,7 @@
1011
from pydantic.networks import EmailStr
1112

1213
from sqlalchemy import DateTime, Column, String, LargeBinary, Integer, Boolean
14+
from sqlalchemy.dialects.postgresql import UUID
1315
from sqlalchemy.orm import relationship
1416
from sqlalchemy.sql.schema import ForeignKey
1517
from sqlalchemy_utils import TSVectorType
@@ -20,7 +22,7 @@
2022
DISPATCH_JWT_EXP,
2123
)
2224
from dispatch.database.core import Base
23-
from dispatch.enums import UserRoles
25+
from dispatch.enums import DispatchEnum, UserRoles
2426
from dispatch.models import OrganizationSlug, PrimaryKey, TimeStampMixin, DispatchBase, Pagination
2527
from dispatch.organization.models import Organization, OrganizationRead
2628
from dispatch.project.models import Project, ProjectRead
@@ -192,3 +194,31 @@ class UserRegisterResponse(DispatchBase):
192194

193195
class UserPagination(Pagination):
194196
items: List[UserRead] = []
197+
198+
199+
class MfaChallengeStatus(DispatchEnum):
200+
PENDING = "pending"
201+
APPROVED = "approved"
202+
DENIED = "denied"
203+
EXPIRED = "expired"
204+
205+
206+
class MfaChallenge(Base, TimeStampMixin):
207+
id = Column(Integer, primary_key=True, autoincrement=True)
208+
valid = Column(Boolean, default=False)
209+
reason = Column(String, nullable=True)
210+
action = Column(String)
211+
status = Column(String, default=MfaChallengeStatus.PENDING)
212+
challenge_id = Column(UUID(as_uuid=True), default=uuid4, unique=True)
213+
dispatch_user_id = Column(Integer, ForeignKey(DispatchUser.id), nullable=False)
214+
dispatch_user = relationship(DispatchUser, backref="mfa_challenges")
215+
216+
217+
class MfaPayloadResponse(DispatchBase):
218+
status: str
219+
220+
221+
class MfaPayload(DispatchBase):
222+
action: str
223+
project_id: int
224+
challenge_id: str

src/dispatch/auth/views.py

+51
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import logging
2+
13
from fastapi import APIRouter, Depends, HTTPException, status
24
from pydantic.error_wrappers import ErrorWrapper, ValidationError
35

@@ -17,9 +19,13 @@
1719
from dispatch.database.service import CommonParameters, search_filter_sort_paginate
1820
from dispatch.enums import UserRoles
1921
from dispatch.models import OrganizationSlug, PrimaryKey
22+
from dispatch.plugin import service as plugin_service
23+
from dispatch.plugins.dispatch_core.exceptions import MfaException
2024
from dispatch.organization.models import OrganizationRead
2125

2226
from .models import (
27+
MfaPayload,
28+
MfaPayloadResponse,
2329
UserLogin,
2430
UserLoginResponse,
2531
UserOrganization,
@@ -33,6 +39,8 @@
3339
from .service import get, get_by_email, update, create
3440

3541

42+
log = logging.getLogger(__name__)
43+
3644
auth_router = APIRouter()
3745
user_router = APIRouter()
3846

@@ -246,6 +254,49 @@ def register_user(
246254
return user
247255

248256

257+
@auth_router.post("/mfa", response_model=MfaPayloadResponse)
258+
def mfa_check(
259+
payload_in: MfaPayload,
260+
current_user: CurrentUser,
261+
db_session: DbSession,
262+
):
263+
log.info(f"MFA check initiated for user: {current_user.email}")
264+
log.debug(f"Payload received: {payload_in.dict()}")
265+
266+
try:
267+
log.info(f"Attempting to get active MFA plugin for project: {payload_in.project_id}")
268+
mfa_auth_plugin = plugin_service.get_active_instance(
269+
db_session=db_session, project_id=payload_in.project_id, plugin_type="auth-mfa"
270+
)
271+
272+
if not mfa_auth_plugin:
273+
log.error(f"MFA plugin not enabled for project: {payload_in.project_id}")
274+
raise HTTPException(
275+
status_code=400, detail="MFA plugin is not enabled for the project."
276+
)
277+
278+
log.info(f"MFA plugin found: {mfa_auth_plugin.__class__.__name__}")
279+
280+
log.info("Validating MFA token")
281+
status = mfa_auth_plugin.instance.validate_mfa_token(payload_in, current_user, db_session)
282+
283+
log.info("MFA token validation successful")
284+
return MfaPayloadResponse(status=status)
285+
286+
except MfaException as e:
287+
log.error(f"MFA Exception occurred: {str(e)}")
288+
log.debug(f"MFA Exception details: {type(e).__name__}", exc_info=True)
289+
raise HTTPException(status_code=400, detail=str(e)) from e
290+
291+
except Exception as e:
292+
log.critical(f"Unexpected error in MFA check: {str(e)}")
293+
log.exception("Full traceback:")
294+
raise HTTPException(status_code=500, detail="An unexpected error occurred") from e
295+
296+
finally:
297+
log.info("MFA check completed")
298+
299+
249300
if DISPATCH_AUTH_REGISTRATION_ENABLED:
250301
register_user = auth_router.post("/register", response_model=UserRegisterResponse)(
251302
register_user
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Adds mfa_challenge to track challenges against core Dispatch MFA plugin.
2+
3+
Revision ID: 51eacaf1f62c
4+
Revises: 71cd7ed999c4
5+
Create Date: 2024-08-09 12:59:54.631968
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
from sqlalchemy.dialects import postgresql
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "51eacaf1f62c"
14+
down_revision = "d6b3853be8e4"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.create_table(
22+
"mfa_challenge",
23+
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
24+
sa.Column("valid", sa.Boolean(), nullable=True, server_default=sa.text("true")),
25+
sa.Column("reason", sa.String(), nullable=True),
26+
sa.Column("action", sa.String(), nullable=True),
27+
sa.Column("challenge_id", postgresql.UUID(as_uuid=True), nullable=True),
28+
sa.Column("dispatch_user_id", sa.Integer(), nullable=False),
29+
sa.Column("status", sa.String(), nullable=True),
30+
sa.Column("updated_at", sa.DateTime(), nullable=True),
31+
sa.Column("created_at", sa.DateTime(), nullable=True),
32+
sa.ForeignKeyConstraint(
33+
["dispatch_user_id"],
34+
["dispatch_core.dispatch_user.id"],
35+
),
36+
sa.PrimaryKeyConstraint("id"),
37+
sa.UniqueConstraint("challenge_id"),
38+
)
39+
# ### end Alembic commands ###
40+
41+
42+
def downgrade():
43+
# ### commands auto generated by Alembic - please adjust! ###
44+
op.drop_table("mfa_challenge")
45+
# ### end Alembic commands ###

src/dispatch/plugins/bases/auth_mfa.py

+6
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ class MultiFactorAuthenticationPlugin(Plugin):
1313

1414
def send_push_notification(self, items, **kwargs):
1515
raise NotImplementedError
16+
17+
def validate_mfa(self, items, **kwargs):
18+
raise NotImplementedError
19+
20+
def create_mfa_challenge(self, items, **kwargs):
21+
raise NotImplementedError
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
class MfaException(Exception):
2+
"""Base exception for MFA-related errors."""
3+
4+
pass
5+
6+
7+
class InvalidChallengeError(MfaException):
8+
"""Raised when the challenge is invalid."""
9+
10+
pass
11+
12+
13+
class UserMismatchError(MfaException):
14+
"""Raised when the challenge doesn't belong to the current user."""
15+
16+
pass
17+
18+
19+
class ActionMismatchError(MfaException):
20+
"""Raised when the action doesn't match the challenge."""
21+
22+
pass
23+
24+
25+
class ExpiredChallengeError(MfaException):
26+
"""Raised when the challenge is no longer valid."""
27+
28+
pass
29+
30+
31+
class InvalidChallengeStateError(MfaException):
32+
"""Raised when the challenge is in an invalid state."""
33+
34+
pass

0 commit comments

Comments
 (0)