Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .fides/db_dataset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,12 @@ dataset:
- name: email_address
data_categories:
- user.contact.email
- name: password_login_enabled
data_categories:
- system.operations
- name: totp_secret
data_categories:
- system.operations
- name: fidesuserpermissions
fields:
- name: created_at
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""add password login enabled and totp secret to fidesuser

Revision ID: 99c603c1b8f9
Revises: 6e565c16dae1
Create Date: 2025-04-02 01:55:57.890545

"""

import sqlalchemy as sa
import sqlalchemy_utils
from alembic import op

# revision identifiers, used by Alembic.
revision = "99c603c1b8f9"
down_revision = "6e565c16dae1"
branch_labels = None
depends_on = None


def upgrade():
op.add_column(
"fidesuser",
sa.Column(
"password_login_enabled",
sa.Boolean(),
nullable=True,
),
)
op.add_column(
"fidesuser",
sa.Column(
"totp_secret",
sqlalchemy_utils.types.encrypted.encrypted_type.StringEncryptedType(),
nullable=True,
),
)
op.alter_column("fidesuser", "hashed_password", nullable=True)
op.alter_column("fidesuser", "salt", nullable=True)


def downgrade():
op.alter_column("fidesuser", "hashed_password", nullable=False)
op.alter_column("fidesuser", "salt", nullable=False)
op.drop_column("fidesuser", "totp_secret")
op.drop_column("fidesuser", "password_login_enabled")
20 changes: 8 additions & 12 deletions src/fides/api/api/v1/endpoints/user_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,7 @@
UserResponse,
UserUpdate,
)
from fides.api.service.user.fides_user_service import (
accept_invite,
invite_user,
perform_login,
)
from fides.api.service.deps import get_user_service
from fides.api.util.api_router import APIRouter
from fides.common.api.scope_registry import (
SCOPE_REGISTRY,
Expand All @@ -75,6 +71,7 @@
from fides.common.api.v1.urn_registry import V1_URL_PREFIX
from fides.config import CONFIG, FidesConfig, get_config
from fides.config.config_proxy import ConfigProxy
from fides.service.user.user_service import UserService

router = APIRouter(tags=["Users"], prefix=V1_URL_PREFIX)

Expand Down Expand Up @@ -423,6 +420,7 @@ def create_user(
db: Session = Depends(get_db),
user_data: UserCreate,
config_proxy: ConfigProxy = Depends(get_config_proxy),
user_service: UserService = Depends(get_user_service),
) -> FidesUser:
"""
Create a user given a username and password.
Expand Down Expand Up @@ -461,7 +459,7 @@ def create_user(
user = FidesUser.create(db=db, data=user_data.model_dump(mode="json"))

# invite user via email
invite_user(db=db, config_proxy=config_proxy, user=user)
user_service.invite_user(user)

logger.info("Created user with id: '{}'.", user.id)
FidesUserPermissions.create(
Expand Down Expand Up @@ -544,6 +542,7 @@ def user_login(
db: Session = Depends(get_db),
config: FidesConfig = Depends(get_config),
user_data: UserLogin,
user_service: UserService = Depends(get_user_service),
) -> UserLoginResponse:
"""Login the user by creating a client if it doesn't exist, and have that client
generate a token."""
Expand Down Expand Up @@ -602,8 +601,7 @@ def user_login(
# from complaining.
user = user_check

client = perform_login(
db,
client = user_service.perform_login(
config.security.oauth_client_id_length_bytes,
config.security.oauth_client_secret_length_bytes,
user,
Expand Down Expand Up @@ -666,9 +664,9 @@ def verify_invite_code(
def accept_user_invite(
*,
db: Session = Depends(get_db),
config: FidesConfig = Depends(get_config),
user_data: UserForcePasswordReset,
verified_invite: FidesUserInvite = Depends(verify_invite_code),
user_service: UserService = Depends(get_user_service),
) -> UserLoginResponse:
"""Sets the password and enables the user if a valid username and invite code are provided."""

Expand All @@ -681,9 +679,7 @@ def accept_user_invite(
detail=f"User with username {verified_invite.username} does not exist.",
)

user, access_code = accept_invite(
db=db, config=config, user=user, new_password=user_data.new_password
)
user, access_code = user_service.accept_invite(user, user_data.new_password)

return UserLoginResponse(
user_data=user,
Expand Down
35 changes: 26 additions & 9 deletions src/fides/api/models/fides_user.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# pylint: disable=unused-import
from __future__ import annotations

import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Any, List

Expand All @@ -10,6 +9,10 @@
from sqlalchemy import Enum as EnumColumn
from sqlalchemy import String
from sqlalchemy.orm import Session, relationship
from sqlalchemy_utils.types.encrypted.encrypted_type import (
AesGcmEngine,
StringEncryptedType,
)

from fides.api.common_exceptions import SystemManagerException
from fides.api.cryptography.cryptographic_util import (
Expand All @@ -20,8 +23,8 @@
from fides.api.models.audit_log import AuditLog

# Intentionally importing SystemManager here to build the FidesUser.systems relationship
from fides.api.models.system_manager import SystemManager # type: ignore[unused-import]
from fides.api.schemas.user import DisabledReason
from fides.config import CONFIG

if TYPE_CHECKING:
from fides.api.models.sql_models import System # type: ignore[attr-defined]
Expand All @@ -34,12 +37,22 @@
email_address = Column(CIText, unique=True, nullable=True)
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
hashed_password = Column(String, nullable=False)
salt = Column(String, nullable=False)
hashed_password = Column(String, nullable=True)
salt = Column(String, nullable=True)
disabled = Column(Boolean, nullable=False, server_default="f")
disabled_reason = Column(EnumColumn(DisabledReason), nullable=True)
last_login_at = Column(DateTime(timezone=True), nullable=True)
password_reset_at = Column(DateTime(timezone=True), nullable=True)
password_login_enabled = Column(Boolean, nullable=True)
totp_secret = Column(
StringEncryptedType(
type_in=String(),
key=CONFIG.security.app_encryption_key,
engine=AesGcmEngine,
padding="pkcs5",
),
nullable=True,
)

# passive_deletes="all" prevents audit logs from having their
# privacy_request_id set to null when a privacy_request is deleted.
Expand Down Expand Up @@ -79,11 +92,11 @@
"""Create a FidesUser by hashing the password with a generated salt
and storing the hashed password and the salt"""

# we set a dummy password if one isn't provided because this means it's part of the user
# invite flow and the password will be set by the user after they accept their invite
hashed_password, salt = FidesUser.hash_password(
data.get("password") or str(uuid.uuid4())
)
if password := data.get("password"):
hashed_password, salt = FidesUser.hash_password(password)
else:
hashed_password = None
salt = None

user = super().create(
db,
Expand All @@ -96,6 +109,7 @@
"last_name": data.get("last_name"),
"disabled": data.get("disabled") or False,
"disabled_reason": data.get("disabled_reason"),
"password_login_enabled": data.get("password_login_enabled"),
},
check_name=check_name,
)
Expand All @@ -104,6 +118,9 @@

def credentials_valid(self, password: str, encoding: str = "UTF-8") -> bool:
"""Verifies that the provided password is correct."""
if self.salt is None:
return False

Check warning on line 122 in src/fides/api/models/fides_user.py

View check run for this annotation

Codecov / codecov/patch

src/fides/api/models/fides_user.py#L122

Added line #L122 was not covered by tests

provided_password_hash = hash_credential_with_salt(
password.encode(encoding),
self.salt.encode(encoding),
Expand Down
2 changes: 2 additions & 0 deletions src/fides/api/models/fides_user_invite.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@

def invite_code_valid(self, invite_code: str, encoding: str = "UTF-8") -> bool:
"""Verifies that the provided invite code is correct."""
if self.salt is None:
return False

Check warning on line 71 in src/fides/api/models/fides_user_invite.py

View check run for this annotation

Codecov / codecov/patch

src/fides/api/models/fides_user_invite.py#L71

Added line #L71 was not covered by tests

invite_code_hash = hash_credential_with_salt(
invite_code.encode(encoding),
Expand Down
6 changes: 5 additions & 1 deletion src/fides/api/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from enum import Enum
from typing import Optional

from pydantic import EmailStr, field_validator
from pydantic import ConfigDict, EmailStr, field_validator

from fides.api.cryptography.cryptographic_util import decode_password
from fides.api.schemas.base_class import FidesSchema
Expand All @@ -27,6 +27,8 @@ class UserCreate(FidesSchema):
last_name: Optional[str] = None
disabled: bool = False

model_config = ConfigDict(extra="ignore")

@field_validator("username")
@classmethod
def validate_username(cls, username: str) -> str:
Expand Down Expand Up @@ -140,6 +142,8 @@ class UserUpdate(FidesSchema):
first_name: Optional[str] = None
last_name: Optional[str] = None

model_config = ConfigDict(extra="ignore")


class DisabledReason(Enum):
"""Reasons for why a user is disabled"""
Expand Down
9 changes: 9 additions & 0 deletions src/fides/api/service/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from fides.service.dataset.dataset_service import DatasetService
from fides.service.messaging.messaging_service import MessagingService
from fides.service.privacy_request.privacy_request_service import PrivacyRequestService
from fides.service.user.user_service import UserService


def get_messaging_service(
Expand All @@ -32,3 +33,11 @@ def get_dataset_service(db: Session = Depends(get_db)) -> DatasetService:

def get_dataset_config_service(db: Session = Depends(get_db)) -> DatasetConfigService:
return DatasetConfigService(db)


def get_user_service(
db: Session = Depends(get_db),
config: FidesConfig = Depends(get_config),
config_proxy: ConfigProxy = Depends(get_config_proxy),
) -> UserService:
return UserService(db, config, config_proxy)
Loading