diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index 3b260e08683..2b1c00802fe 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -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 diff --git a/src/fides/api/alembic/migrations/versions/99c603c1b8f9_add_password_login_enabled_and_totp_secret_to_fidesuser.py b/src/fides/api/alembic/migrations/versions/99c603c1b8f9_add_password_login_enabled_and_totp_secret_to_fidesuser.py new file mode 100644 index 00000000000..7399e3d475a --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/99c603c1b8f9_add_password_login_enabled_and_totp_secret_to_fidesuser.py @@ -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") diff --git a/src/fides/api/api/v1/endpoints/user_endpoints.py b/src/fides/api/api/v1/endpoints/user_endpoints.py index aeb9e674e44..8a1f3481a8f 100644 --- a/src/fides/api/api/v1/endpoints/user_endpoints.py +++ b/src/fides/api/api/v1/endpoints/user_endpoints.py @@ -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, @@ -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) @@ -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. @@ -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( @@ -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.""" @@ -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, @@ -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.""" @@ -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, diff --git a/src/fides/api/models/fides_user.py b/src/fides/api/models/fides_user.py index 310893ff701..ba9fc6562b8 100644 --- a/src/fides/api/models/fides_user.py +++ b/src/fides/api/models/fides_user.py @@ -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 @@ -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 ( @@ -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] @@ -34,12 +37,22 @@ class FidesUser(Base): 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. @@ -79,11 +92,11 @@ def create( """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, @@ -96,6 +109,7 @@ def create( "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, ) @@ -104,6 +118,9 @@ def create( 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 + provided_password_hash = hash_credential_with_salt( password.encode(encoding), self.salt.encode(encoding), diff --git a/src/fides/api/models/fides_user_invite.py b/src/fides/api/models/fides_user_invite.py index 96d6d08574d..5523f950396 100644 --- a/src/fides/api/models/fides_user_invite.py +++ b/src/fides/api/models/fides_user_invite.py @@ -67,6 +67,8 @@ def create( 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 invite_code_hash = hash_credential_with_salt( invite_code.encode(encoding), diff --git a/src/fides/api/schemas/user.py b/src/fides/api/schemas/user.py index 91a1a0e102b..64ebaf3ac87 100644 --- a/src/fides/api/schemas/user.py +++ b/src/fides/api/schemas/user.py @@ -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 @@ -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: @@ -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""" diff --git a/src/fides/api/service/deps.py b/src/fides/api/service/deps.py index 92ad66445d3..475826cb2db 100644 --- a/src/fides/api/service/deps.py +++ b/src/fides/api/service/deps.py @@ -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( @@ -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) diff --git a/src/fides/api/service/user/fides_user_service.py b/src/fides/api/service/user/fides_user_service.py deleted file mode 100644 index 9e2dc68da5e..00000000000 --- a/src/fides/api/service/user/fides_user_service.py +++ /dev/null @@ -1,128 +0,0 @@ -import uuid -from datetime import datetime -from typing import Optional, Tuple - -from loguru import logger -from sqlalchemy.orm import Session - -from fides.api.api.v1.endpoints.messaging_endpoints import user_email_invite_status -from fides.api.common_exceptions import AuthorizationError -from fides.api.models.client import ClientDetail -from fides.api.models.fides_user import FidesUser -from fides.api.models.fides_user_invite import FidesUserInvite -from fides.api.schemas.messaging.messaging import ( - MessagingActionType, - UserInviteBodyParams, -) -from fides.api.schemas.redis_cache import Identity -from fides.api.service.messaging.message_dispatch_service import dispatch_message -from fides.config import FidesConfig -from fides.config.config_proxy import ConfigProxy - - -def invite_user(db: Session, config_proxy: ConfigProxy, user: FidesUser) -> None: - """ - Generates a user invite and sends the invite code to the user via email. - - This is a no-op if email messaging isn't configured. - """ - - # invite user via email if email messaging is enabled and the Admin UI URL is defined - if user_email_invite_status(db=db, config_proxy=config_proxy).enabled: - invite_code = str(uuid.uuid4()) - FidesUserInvite.create( - db=db, data={"username": user.username, "invite_code": invite_code} - ) - user.update(db, data={"disabled": True}) - dispatch_message( - db, - action_type=MessagingActionType.USER_INVITE, - to_identity=Identity(email=user.email_address), - service_type=config_proxy.notifications.notification_service_type, - message_body_params=UserInviteBodyParams( - username=user.username, invite_code=invite_code - ), - ) - - -def accept_invite( - db: Session, config: FidesConfig, user: FidesUser, new_password: str -) -> Tuple[FidesUser, str]: - """ - Updates the user password and enables the user. Also removes the user invite from the database. - Returns a tuple of the updated user and their access code. - """ - - # update password and enable - user.update_password(db=db, new_password=new_password) - user.update( - db, - data={"disabled": False, "disabled_reason": None}, - ) - db.refresh(user) - - # delete invite - if user.username: - invite = FidesUserInvite.get_by(db=db, field="username", value=user.username) - if invite: - invite.delete(db) - else: - logger.warning("Username is missing, skipping invite deletion.") - - client = perform_login( - db, - config.security.oauth_client_id_length_bytes, - config.security.oauth_client_secret_length_bytes, - user, - ) - - logger.info("Creating login access token") - access_code = client.create_access_code_jwe(config.security.app_encryption_key) - - return user, access_code - - -def perform_login( - db: Session, - client_id_byte_length: int, - client_secret_byte_length: int, - user: FidesUser, - skip_save: Optional[bool] = False, -) -> ClientDetail: - """Performs a login by updating the FidesUser instance and creating and returning - an associated ClientDetail. - - If the username or password was bad, skip_save should be True. We still run through - parallel operations to keep the timing of operations similar, but should skip - saving to the database. - """ - - client = user.client - if not client: - logger.info("Creating client for login") - client, _ = ClientDetail.create_client_and_secret( - db, - client_id_byte_length, - client_secret_byte_length, - scopes=[], # type: ignore - roles=user.permissions.roles, # type: ignore - systems=user.system_ids, # type: ignore - user_id=user.id, - in_memory=skip_save, # If login flow has already errored, don't persist this to the database - ) - else: - # Refresh the client just in case - for example, scopes and roles were added via the db directly. - client.roles = user.permissions.roles # type: ignore - client.systems = user.system_ids # type: ignore - if not skip_save: - client.save(db) - - if user.permissions and (not user.permissions.roles and not user.systems): # type: ignore - logger.warning("User {} needs roles or systems to login.", user.id) - raise AuthorizationError(detail="Not Authorized for this action") - - if not skip_save: - user.last_login_at = datetime.utcnow() - user.save(db) - - return client diff --git a/src/fides/service/user/__init__.py b/src/fides/service/user/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/fides/service/user/user_service.py b/src/fides/service/user/user_service.py new file mode 100644 index 00000000000..1dd850fa2f4 --- /dev/null +++ b/src/fides/service/user/user_service.py @@ -0,0 +1,140 @@ +import uuid +from datetime import datetime +from typing import Optional, Tuple + +from loguru import logger +from sqlalchemy.orm import Session + +from fides.api.api.v1.endpoints.messaging_endpoints import user_email_invite_status +from fides.api.common_exceptions import AuthorizationError +from fides.api.models.client import ClientDetail +from fides.api.models.fides_user import FidesUser +from fides.api.models.fides_user_invite import FidesUserInvite +from fides.api.schemas.messaging.messaging import ( + MessagingActionType, + UserInviteBodyParams, +) +from fides.api.schemas.redis_cache import Identity +from fides.api.service.messaging.message_dispatch_service import dispatch_message +from fides.config import FidesConfig +from fides.config.config_proxy import ConfigProxy + + +class UserService: + def __init__(self, db: Session, config: FidesConfig, config_proxy: ConfigProxy): + self.db = db + self.config = config + self.config_proxy = config_proxy + + def invite_user(self, user: FidesUser) -> None: + """ + Generates a user invite and sends the invite code to the user via email. + + This is a no-op if email messaging isn't configured. + """ + + # invite user via email if email messaging is enabled and the Admin UI URL is defined + if user_email_invite_status(db=self.db, config_proxy=self.config_proxy).enabled: + invite_code = str(uuid.uuid4()) + FidesUserInvite.create( + db=self.db, data={"username": user.username, "invite_code": invite_code} + ) + user.update(self.db, data={"disabled": True}) + # TODO: refactor to use MessagingService + dispatch_message( + self.db, + action_type=MessagingActionType.USER_INVITE, + to_identity=Identity(email=user.email_address), + service_type=self.config_proxy.notifications.notification_service_type, + message_body_params=UserInviteBodyParams( + username=user.username, invite_code=invite_code + ), + ) + else: + logger.debug( + "Skipping invitation email, an email messaging provider is not enabled", + ) + + def perform_login( + self, + client_id_byte_length: int, + client_secret_byte_length: int, + user: FidesUser, + skip_save: Optional[bool] = False, + ) -> ClientDetail: + """Performs a login by updating the FidesUser instance and creating and returning + an associated ClientDetail. + + If the username or password was bad, skip_save should be True. We still run through + parallel operations to keep the timing of operations similar, but should skip + saving to the database. + """ + + client = user.client + if not client: + logger.info("Creating client for login") + client, _ = ClientDetail.create_client_and_secret( + self.db, + client_id_byte_length, + client_secret_byte_length, + scopes=[], # type: ignore + roles=user.permissions.roles, # type: ignore + systems=user.system_ids, # type: ignore + user_id=user.id, + in_memory=skip_save, # If login flow has already errored, don't persist this to the database + ) + else: + # Refresh the client just in case - for example, scopes and roles were added via the db directly. + client.roles = user.permissions.roles # type: ignore + client.systems = user.system_ids # type: ignore + if not skip_save: + client.save(self.db) + + if user.permissions and (not user.permissions.roles and not user.systems): # type: ignore + logger.warning("User {} needs roles or systems to login.", user.id) + raise AuthorizationError(detail="Not Authorized for this action") + + if not skip_save: + user.last_login_at = datetime.utcnow() + user.save(self.db) + + return client + + def accept_invite( + self, user: FidesUser, new_password: str + ) -> Tuple[FidesUser, str]: + """ + Updates the user password and enables the user. Also removes the user invite from the database. + Returns a tuple of the updated user and their access code. + """ + + # update password and enable + user.update_password(db=self.db, new_password=new_password) + user.update( + self.db, + data={"disabled": False, "disabled_reason": None}, + ) + self.db.refresh(user) + + # delete invite + if user.username: + invite = FidesUserInvite.get_by( + db=self.db, field="username", value=user.username + ) + if invite: + invite.delete(self.db) + else: + logger.warning("Username is missing, skipping invite deletion.") + + client = self.perform_login( + self.config.security.oauth_client_id_length_bytes, + self.config.security.oauth_client_secret_length_bytes, + user, + ) + + logger.info("Creating login access token") + access_code = client.create_access_code_jwe( + self.config.security.app_encryption_key + ) + + return user, access_code