diff --git a/src/dispatch/auth/models.py b/src/dispatch/auth/models.py index e430a3df8665..6257e9f1d53d 100644 --- a/src/dispatch/auth/models.py +++ b/src/dispatch/auth/models.py @@ -65,9 +65,23 @@ class DispatchUser(Base, TimeStampMixin): TSVectorType("email", regconfig="pg_catalog.simple", weights={"email": "A"}) ) - def check_password(self, password): + def verify_password(self, password: str) -> bool: + """Verify if provided password matches stored hash""" + if not password or not self.password: + return False return bcrypt.checkpw(password.encode("utf-8"), self.password) + def set_password(self, password: str) -> None: + """Set a new password""" + if not password: + raise ValueError("Password cannot be empty") + self.password = hash_password(password) + + def is_owner(self, organization_slug: str) -> bool: + """Check if user is an owner in the given organization""" + role = self.get_organization_role(organization_slug) + return role == UserRoles.owner + @property def token(self): now = datetime.utcnow() @@ -165,15 +179,51 @@ class UserRead(UserBase): class UserUpdate(DispatchBase): id: PrimaryKey - password: Optional[str] = Field(None, nullable=True) projects: Optional[List[UserProject]] organizations: Optional[List[UserOrganization]] experimental_features: Optional[bool] role: Optional[str] = Field(None, nullable=True) - @validator("password", pre=True) - def hash(cls, v): - return hash_password(str(v)) + +class UserPasswordUpdate(DispatchBase): + """Model for password updates only""" + current_password: str + new_password: str + + @validator("new_password") + def validate_password(cls, v): + if not v or len(v) < 8: + raise ValueError("Password must be at least 8 characters long") + # Check for at least one number + if not any(c.isdigit() for c in v): + raise ValueError("Password must contain at least one number") + # Check for at least one uppercase and one lowercase character + if not (any(c.isupper() for c in v) and any(c.islower() for c in v)): + raise ValueError("Password must contain both uppercase and lowercase characters") + return v + + @validator("current_password") + def password_required(cls, v): + if not v: + raise ValueError("Current password is required") + return v + + +class AdminPasswordReset(DispatchBase): + """Model for admin password resets""" + new_password: str + + @validator("new_password") + def validate_password(cls, v): + if not v or len(v) < 8: + raise ValueError("Password must be at least 8 characters long") + # Check for at least one number + if not any(c.isdigit() for c in v): + raise ValueError("Password must contain at least one number") + # Check for at least one uppercase and one lowercase character + if not (any(c.isupper() for c in v) and any(c.islower() for c in v)): + raise ValueError("Password must contain both uppercase and lowercase characters") + return v class UserCreate(DispatchBase): diff --git a/src/dispatch/auth/views.py b/src/dispatch/auth/views.py index 4b4d1c3bdaed..e0d9bbaf7037 100644 --- a/src/dispatch/auth/views.py +++ b/src/dispatch/auth/views.py @@ -35,6 +35,8 @@ UserRegisterResponse, UserCreate, UserUpdate, + UserPasswordUpdate, + AdminPasswordReset, ) from .service import get, get_by_email, update, create @@ -159,7 +161,6 @@ def update_user( # New user role provided is different than current user role current_user_organization_role = current_user.get_organization_role(organization) if current_user_organization_role != UserRoles.owner: - # We don't allow the role change if user requesting the change does not have owner role raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=[ @@ -174,12 +175,88 @@ def update_user( UserOrganization(role=user_in.role, organization=OrganizationRead(name=organization)) ] - # we currently only allow user password changes via CLI, not UI/API. - user_in.password = None - return update(db_session=db_session, user=user, user_in=user_in) +@user_router.post("/{user_id}/change-password", response_model=UserRead) +def change_password( + db_session: DbSession, + user_id: PrimaryKey, + password_update: UserPasswordUpdate, + current_user: CurrentUser, + organization: OrganizationSlug, +): + """Change user password with proper validation""" + user = get(db_session=db_session, user_id=user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=[{"msg": "A user with this id does not exist."}], + ) + + # Only allow users to change their own password or owners to reset + if user.id != current_user.id and not current_user.is_owner(organization): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=[{"msg": "Not authorized to change other user passwords"}], + ) + + # Validate current password if user is changing their own password + if user.id == current_user.id: + if not user.verify_password(password_update.current_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=[{"msg": "Invalid current password"}], + ) + + # Set new password + try: + user.set_password(password_update.new_password) + db_session.commit() + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=[{"msg": str(e)}], + ) from e + + return user + + +@user_router.post("/{user_id}/reset-password", response_model=UserRead) +def admin_reset_password( + db_session: DbSession, + user_id: PrimaryKey, + password_reset: AdminPasswordReset, + current_user: CurrentUser, + organization: OrganizationSlug, +): + """Admin endpoint to reset user password""" + # Verify current user is an owner + if not current_user.is_owner(organization): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=[{"msg": "Only owners can reset passwords"}], + ) + + user = get(db_session=db_session, user_id=user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=[{"msg": "A user with this id does not exist."}], + ) + + try: + user.set_password(password_reset.new_password) + db_session.commit() + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=[{"msg": str(e)}], + ) from e + + return user + + @auth_router.get("/me", response_model=UserRead) def get_me( *, @@ -206,7 +283,7 @@ def login_user( db_session: DbSession, ): user = get_by_email(db_session=db_session, email=user_in.email) - if user and user.check_password(user_in.password): + if user and user.verify_password(user_in.password): projects = [] for user_project in user.projects: projects.append( diff --git a/src/dispatch/cli.py b/src/dispatch/cli.py index 753020726107..e062946c68a0 100644 --- a/src/dispatch/cli.py +++ b/src/dispatch/cli.py @@ -242,7 +242,6 @@ def update_user(email: str, role: str, organization: str): def reset_user_password(email: str, password: str): """Resets a user's password.""" from dispatch.auth import service as user_service - from dispatch.auth.models import UserUpdate from dispatch.database.core import SessionLocal db_session = SessionLocal() @@ -251,10 +250,14 @@ def reset_user_password(email: str, password: str): click.secho(f"No user found. Email: {email}", fg="red") return - user_service.update( - user=user, user_in=UserUpdate(id=user.id, password=password), db_session=db_session - ) - click.secho("User successfully updated.", fg="green") + try: + # Use the new set_password method which includes validation + user.set_password(password) + db_session.commit() + click.secho("User password successfully updated.", fg="green") + except ValueError as e: + click.secho(f"Failed to update password: {str(e)}", fg="red") + return @dispatch_cli.group("database") diff --git a/src/dispatch/forms/models.py b/src/dispatch/forms/models.py index 5a7bd953f193..435c76a7d4e2 100644 --- a/src/dispatch/forms/models.py +++ b/src/dispatch/forms/models.py @@ -9,7 +9,7 @@ from dispatch.individual.models import IndividualContactReadMinimal from dispatch.models import DispatchBase, TimeStampMixin, PrimaryKey, Pagination, ProjectMixin from dispatch.project.models import ProjectRead -from dispatch.incident.models import IncidentReadMinimal +from dispatch.incident.models import IncidentReadBasic from dispatch.forms.type.models import FormsTypeRead from .enums import FormStatus, FormAttorneyStatus @@ -46,7 +46,7 @@ class FormsBase(DispatchBase): status: Optional[str] = Field(None, nullable=True) attorney_status: Optional[str] = Field(None, nullable=True) project: Optional[ProjectRead] - incident: Optional[IncidentReadMinimal] + incident: Optional[IncidentReadBasic] attorney_questions: Optional[str] = Field(None, nullable=True) attorney_analysis: Optional[str] = Field(None, nullable=True) score: Optional[int] diff --git a/src/dispatch/static/dispatch/src/dashboard/DashboardCard.vue b/src/dispatch/static/dispatch/src/dashboard/DashboardCard.vue index 3075135ef4d1..5815027738ea 100644 --- a/src/dispatch/static/dispatch/src/dashboard/DashboardCard.vue +++ b/src/dispatch/static/dispatch/src/dashboard/DashboardCard.vue @@ -1,12 +1,14 @@