From 0fd4038e7517a895711d1879c833c68d3db718d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:31:56 +0000 Subject: [PATCH 1/4] feat: add strict_password_checking config option to relax password requirements - Add `strict_password_checking: bool = Field(default=False)` to InvokeAIAppConfig - Add `get_password_strength()` function to password_utils.py (returns weak/moderate/strong) - Add `strict_password_checking` field to SetupStatusResponse API endpoint - Update users_base.py and users_default.py to accept `strict_password_checking` param - Update auth.py router to pass config.strict_password_checking to all user service calls - Create shared frontend utility passwordUtils.ts for password strength validation - Update AdministratorSetup, UserProfile, UserManagement components to: - Fetch strict_password_checking from setup status endpoint - Show colored strength indicators (red/yellow/blue) in non-strict mode - Allow any non-empty password in non-strict mode - Maintain strict validation behavior when strict_password_checking=True - Update SetupStatusResponse type in auth.ts endpoint - Add passwordStrength and passwordHelperRelaxed translation keys to en.json - Add tests for new get_password_strength() function Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- invokeai/app/api/routers/auth.py | 22 ++++-- invokeai/app/services/auth/password_utils.py | 29 +++++++- .../app/services/config/config_default.py | 2 + invokeai/app/services/users/users_base.py | 18 +++-- invokeai/app/services/users/users_default.py | 26 ++++--- invokeai/frontend/web/public/locales/en.json | 8 ++- .../auth/components/AdministratorSetup.tsx | 44 ++++++------ .../auth/components/UserManagement.tsx | 41 +++++------ .../features/auth/components/UserProfile.tsx | 45 ++++++------ .../src/features/auth/util/passwordUtils.ts | 70 +++++++++++++++++++ .../web/src/services/api/endpoints/auth.ts | 1 + .../frontend/web/src/services/api/schema.ts | 12 ++++ .../app/services/auth/test_password_utils.py | 59 +++++++++++++++- 13 files changed, 287 insertions(+), 90 deletions(-) create mode 100644 invokeai/frontend/web/src/features/auth/util/passwordUtils.ts diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py index 2e7e49c41e3..b4c1e86cf33 100644 --- a/invokeai/app/api/routers/auth.py +++ b/invokeai/app/api/routers/auth.py @@ -79,6 +79,7 @@ class SetupStatusResponse(BaseModel): setup_required: bool = Field(description="Whether initial setup is required") multiuser_enabled: bool = Field(description="Whether multiuser mode is enabled") + strict_password_checking: bool = Field(description="Whether strict password requirements are enforced") @auth_router.get("/status", response_model=SetupStatusResponse) @@ -92,13 +93,17 @@ async def get_setup_status() -> SetupStatusResponse: # If multiuser is disabled, setup is never required if not config.multiuser: - return SetupStatusResponse(setup_required=False, multiuser_enabled=False) + return SetupStatusResponse( + setup_required=False, multiuser_enabled=False, strict_password_checking=config.strict_password_checking + ) # In multiuser mode, check if an admin exists user_service = ApiDependencies.invoker.services.users setup_required = not user_service.has_admin() - return SetupStatusResponse(setup_required=setup_required, multiuser_enabled=True) + return SetupStatusResponse( + setup_required=setup_required, multiuser_enabled=True, strict_password_checking=config.strict_password_checking + ) @auth_router.post("/login", response_model=LoginResponse) @@ -248,7 +253,7 @@ async def setup_admin( password=request.password, is_admin=True, ) - user = user_service.create_admin(user_data) + user = user_service.create_admin(user_data, strict_password_checking=config.strict_password_checking) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e @@ -359,6 +364,7 @@ async def create_user( HTTPException: 400 if email already exists or password is weak """ user_service = ApiDependencies.invoker.services.users + config = ApiDependencies.invoker.services.configuration try: user_data = UserCreateRequest( email=request.email, @@ -366,7 +372,7 @@ async def create_user( password=request.password, is_admin=request.is_admin, ) - return user_service.create(user_data) + return user_service.create(user_data, strict_password_checking=config.strict_password_checking) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e @@ -414,6 +420,7 @@ async def update_user( HTTPException: 404 if user not found """ user_service = ApiDependencies.invoker.services.users + config = ApiDependencies.invoker.services.configuration try: changes = UserUpdateRequest( display_name=request.display_name, @@ -421,7 +428,7 @@ async def update_user( is_admin=request.is_admin, is_active=request.is_active, ) - return user_service.update(user_id, changes) + return user_service.update(user_id, changes, strict_password_checking=config.strict_password_checking) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e @@ -483,6 +490,7 @@ async def update_current_user( HTTPException: 404 if user not found """ user_service = ApiDependencies.invoker.services.users + config = ApiDependencies.invoker.services.configuration # Verify current password when attempting a password change if request.new_password is not None: @@ -509,6 +517,8 @@ async def update_current_user( display_name=request.display_name, password=request.new_password, ) - return user_service.update(current_user.user_id, changes) + return user_service.update( + current_user.user_id, changes, strict_password_checking=config.strict_password_checking + ) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e diff --git a/invokeai/app/services/auth/password_utils.py b/invokeai/app/services/auth/password_utils.py index 5e641516347..b960af5f1c5 100644 --- a/invokeai/app/services/auth/password_utils.py +++ b/invokeai/app/services/auth/password_utils.py @@ -1,6 +1,6 @@ """Password hashing and validation utilities.""" -from typing import cast +from typing import Literal, cast from passlib.context import CryptContext @@ -84,3 +84,30 @@ def validate_password_strength(password: str) -> tuple[bool, str]: return False, "Password must contain uppercase, lowercase, and numbers" return True, "" + + +def get_password_strength(password: str) -> Literal["weak", "moderate", "strong"]: + """Determine the strength of a password. + + Strength levels: + - weak: less than 8 characters + - moderate: 8+ characters but missing at least one of uppercase, lowercase, or digit + - strong: 8+ characters with uppercase, lowercase, and digit + + Args: + password: The password to evaluate + + Returns: + One of "weak", "moderate", or "strong" + """ + if len(password) < 8: + return "weak" + + has_upper = any(c.isupper() for c in password) + has_lower = any(c.islower() for c in password) + has_digit = any(c.isdigit() for c in password) + + if not (has_upper and has_lower and has_digit): + return "moderate" + + return "strong" diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 2cc2aaf273c..dc8186f2a33 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -111,6 +111,7 @@ class InvokeAIAppConfig(BaseSettings): unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production. allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation. multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization. + strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength is reported to the user. """ _root: Optional[Path] = PrivateAttr(default=None) @@ -206,6 +207,7 @@ class InvokeAIAppConfig(BaseSettings): # MULTIUSER multiuser: bool = Field(default=False, description="Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization.") + strict_password_checking: bool = Field(default=False, description="Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user.") # fmt: on diff --git a/invokeai/app/services/users/users_base.py b/invokeai/app/services/users/users_base.py index 5ad66c59832..728a0adfa37 100644 --- a/invokeai/app/services/users/users_base.py +++ b/invokeai/app/services/users/users_base.py @@ -9,17 +9,19 @@ class UserServiceBase(ABC): """High-level service for user management.""" @abstractmethod - def create(self, user_data: UserCreateRequest) -> UserDTO: + def create(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO: """Create a new user. Args: user_data: User creation data + strict_password_checking: If True (default), passwords must meet strength requirements. + If False, any non-empty password is accepted. Returns: The created user Raises: - ValueError: If email already exists or password is weak + ValueError: If email already exists or (when strict) password is weak """ pass @@ -48,18 +50,20 @@ def get_by_email(self, email: str) -> UserDTO | None: pass @abstractmethod - def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO: + def update(self, user_id: str, changes: UserUpdateRequest, strict_password_checking: bool = True) -> UserDTO: """Update user. Args: user_id: The user ID changes: Fields to update + strict_password_checking: If True (default), passwords must meet strength requirements. + If False, any non-empty password is accepted. Returns: The updated user Raises: - ValueError: If user not found or password is weak + ValueError: If user not found or (when strict) password is weak """ pass @@ -98,17 +102,19 @@ def has_admin(self) -> bool: pass @abstractmethod - def create_admin(self, user_data: UserCreateRequest) -> UserDTO: + def create_admin(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO: """Create an admin user (for initial setup). Args: user_data: User creation data + strict_password_checking: If True (default), passwords must meet strength requirements. + If False, any non-empty password is accepted. Returns: The created admin user Raises: - ValueError: If admin already exists or password is weak + ValueError: If admin already exists or (when strict) password is weak """ pass diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py index 506ae937f02..709e4cb82c6 100644 --- a/invokeai/app/services/users/users_default.py +++ b/invokeai/app/services/users/users_default.py @@ -21,12 +21,15 @@ def __init__(self, db: SqliteDatabase): """ self._db = db - def create(self, user_data: UserCreateRequest) -> UserDTO: + def create(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO: """Create a new user.""" # Validate password strength - is_valid, error_msg = validate_password_strength(user_data.password) - if not is_valid: - raise ValueError(error_msg) + if strict_password_checking: + is_valid, error_msg = validate_password_strength(user_data.password) + if not is_valid: + raise ValueError(error_msg) + elif not user_data.password: + raise ValueError("Password cannot be empty") # Check if email already exists if self.get_by_email(user_data.email) is not None: @@ -106,7 +109,7 @@ def get_by_email(self, email: str) -> UserDTO | None: last_login_at=datetime.fromisoformat(row[7]) if row[7] else None, ) - def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO: + def update(self, user_id: str, changes: UserUpdateRequest, strict_password_checking: bool = True) -> UserDTO: """Update user.""" # Check if user exists user = self.get(user_id) @@ -115,9 +118,12 @@ def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO: # Validate password if provided if changes.password is not None: - is_valid, error_msg = validate_password_strength(changes.password) - if not is_valid: - raise ValueError(error_msg) + if strict_password_checking: + is_valid, error_msg = validate_password_strength(changes.password) + if not is_valid: + raise ValueError(error_msg) + elif not changes.password: + raise ValueError("Password cannot be empty") # Build update query dynamically based on provided fields updates: list[str] = [] @@ -208,7 +214,7 @@ def has_admin(self) -> bool: count = row[0] if row else 0 return bool(count > 0) - def create_admin(self, user_data: UserCreateRequest) -> UserDTO: + def create_admin(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO: """Create an admin user (for initial setup).""" if self.has_admin(): raise ValueError("Admin user already exists") @@ -220,7 +226,7 @@ def create_admin(self, user_data: UserCreateRequest) -> UserDTO: password=user_data.password, is_admin=True, ) - return self.create(admin_data) + return self.create(admin_data, strict_password_checking=strict_password_checking) def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]: """List all users.""" diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 617c4341574..58be5430a26 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -46,7 +46,8 @@ "passwordsDoNotMatch": "Passwords do not match", "createAccount": "Create Administrator Account", "creatingAccount": "Setting up...", - "setupFailed": "Setup failed. Please try again." + "setupFailed": "Setup failed. Please try again.", + "passwordHelperRelaxed": "Enter any password (strength will be shown)" }, "userMenu": "User Menu", "admin": "Admin", @@ -102,6 +103,11 @@ "back": "Back", "cannotDeleteSelf": "You cannot delete your own account", "cannotDeactivateSelf": "You cannot deactivate your own account" + }, + "passwordStrength": { + "weak": "Weak password", + "moderate": "Moderate password", + "strong": "Strong password" } }, "boards": { diff --git a/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx b/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx index 9827a4d9769..b0ad9a5e047 100644 --- a/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx +++ b/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx @@ -15,34 +15,13 @@ import { Text, VStack, } from '@invoke-ai/ui-library'; +import { validatePasswordField } from 'features/auth/util/passwordUtils'; import type { ChangeEvent, FormEvent } from 'react'; import { memo, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useGetSetupStatusQuery, useSetupMutation } from 'services/api/endpoints/auth'; -const validatePasswordStrength = ( - password: string, - t: (key: string) => string -): { isValid: boolean; message: string } => { - if (password.length < 8) { - return { isValid: false, message: t('auth.setup.passwordTooShort') }; - } - - const hasUpper = /[A-Z]/.test(password); - const hasLower = /[a-z]/.test(password); - const hasDigit = /\d/.test(password); - - if (!hasUpper || !hasLower || !hasDigit) { - return { - isValid: false, - message: t('auth.setup.passwordMissingRequirements'), - }; - } - - return { isValid: true, message: '' }; -}; - export const AdministratorSetup = memo(() => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -60,7 +39,8 @@ export const AdministratorSetup = memo(() => { } }, [setupStatus, isLoadingSetup, navigate]); - const passwordValidation = validatePasswordStrength(password, t); + const strictPasswordChecking = setupStatus?.strict_password_checking ?? true; + const passwordValidation = validatePasswordField(password, t, strictPasswordChecking, false); const passwordsMatch = password === confirmPassword; const handleSubmit = useCallback( @@ -120,6 +100,13 @@ export const AdministratorSetup = memo(() => { ); } + const passwordStrengthColor = + passwordValidation.strength === 'weak' + ? 'error.300' + : passwordValidation.strength === 'moderate' + ? 'warning.300' + : 'invokeBlue.300'; + return (
@@ -192,7 +179,16 @@ export const AdministratorSetup = memo(() => { {password.length > 0 && !passwordValidation.isValid && ( {passwordValidation.message} )} - {password.length === 0 && {t('auth.setup.passwordHelper')}} + {password.length > 0 && passwordValidation.isValid && passwordValidation.message && ( + + {passwordValidation.message} + + )} + {password.length === 0 && ( + + {strictPasswordChecking ? t('auth.setup.passwordHelper') : t('auth.setup.passwordHelperRelaxed')} + + )} diff --git a/invokeai/frontend/web/src/features/auth/components/UserManagement.tsx b/invokeai/frontend/web/src/features/auth/components/UserManagement.tsx index 4dd88ca1e5a..8d587e7249e 100644 --- a/invokeai/frontend/web/src/features/auth/components/UserManagement.tsx +++ b/invokeai/frontend/web/src/features/auth/components/UserManagement.tsx @@ -37,6 +37,7 @@ import { } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { selectCurrentUser } from 'features/auth/store/authSlice'; +import { validatePasswordField } from 'features/auth/util/passwordUtils'; import type { ChangeEvent, FormEvent } from 'react'; import { memo, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -54,30 +55,12 @@ import type { UserDTO } from 'services/api/endpoints/auth'; import { useCreateUserMutation, useDeleteUserMutation, + useGetSetupStatusQuery, useLazyGeneratePasswordQuery, useListUsersQuery, useUpdateUserMutation, } from 'services/api/endpoints/auth'; -const validatePasswordStrength = ( - password: string, - t: (key: string) => string -): { isValid: boolean; message: string } => { - if (password.length === 0) { - return { isValid: true, message: '' }; - } - if (password.length < 8) { - return { isValid: false, message: t('auth.setup.passwordTooShort') }; - } - const hasUpper = /[A-Z]/.test(password); - const hasLower = /[a-z]/.test(password); - const hasDigit = /\d/.test(password); - if (!hasUpper || !hasLower || !hasDigit) { - return { isValid: false, message: t('auth.setup.passwordMissingRequirements') }; - } - return { isValid: true, message: '' }; -}; - const FORM_GRID_COLUMNS = '120px 1fr'; // --------------------------------------------------------------------------- @@ -105,9 +88,12 @@ const UserFormModal = memo(({ isOpen, onClose, editUser }: UserFormModalProps) = const [createUser, { isLoading: isCreating }] = useCreateUserMutation(); const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation(); const [triggerGeneratePassword] = useLazyGeneratePasswordQuery(); + const { data: setupStatus } = useGetSetupStatusQuery(); const isLoading = isCreating || isUpdating; - const passwordValidation = validatePasswordStrength(password, t); + const strictPasswordChecking = setupStatus?.strict_password_checking ?? true; + // In edit mode, empty password means "no change" (allowEmpty=true); in create mode password is required (allowEmpty=false) + const passwordValidation = validatePasswordField(password, t, strictPasswordChecking, isEdit); const handleGeneratePassword = useCallback(async () => { try { @@ -300,6 +286,21 @@ const UserFormModal = memo(({ isOpen, onClose, editUser }: UserFormModalProps) = {password.length > 0 && !passwordValidation.isValid && ( {passwordValidation.message} )} + {password.length > 0 && passwordValidation.isValid && passwordValidation.message && ( + + {passwordValidation.message} + + )} diff --git a/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx b/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx index 4504698f0ea..02d25b6de98 100644 --- a/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx +++ b/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx @@ -21,31 +21,17 @@ import { } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectAuthToken, selectCurrentUser, setCredentials } from 'features/auth/store/authSlice'; +import { validatePasswordField } from 'features/auth/util/passwordUtils'; import type { ChangeEvent, FormEvent } from 'react'; import { memo, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PiEyeBold, PiEyeSlashBold, PiLightningFill } from 'react-icons/pi'; import { useNavigate } from 'react-router-dom'; -import { useLazyGeneratePasswordQuery, useUpdateCurrentUserMutation } from 'services/api/endpoints/auth'; - -const validatePasswordStrength = ( - password: string, - t: (key: string) => string -): { isValid: boolean; message: string } => { - if (password.length === 0) { - return { isValid: true, message: '' }; - } - if (password.length < 8) { - return { isValid: false, message: t('auth.setup.passwordTooShort') }; - } - const hasUpper = /[A-Z]/.test(password); - const hasLower = /[a-z]/.test(password); - const hasDigit = /\d/.test(password); - if (!hasUpper || !hasLower || !hasDigit) { - return { isValid: false, message: t('auth.setup.passwordMissingRequirements') }; - } - return { isValid: true, message: '' }; -}; +import { + useGetSetupStatusQuery, + useLazyGeneratePasswordQuery, + useUpdateCurrentUserMutation, +} from 'services/api/endpoints/auth'; const PASSWORD_GRID_COLUMNS = '180px 1fr'; @@ -67,8 +53,10 @@ export const UserProfile = memo(() => { const [updateCurrentUser, { isLoading }] = useUpdateCurrentUserMutation(); const [triggerGeneratePassword] = useLazyGeneratePasswordQuery(); + const { data: setupStatus } = useGetSetupStatusQuery(); - const newPasswordValidation = validatePasswordStrength(newPassword, t); + const strictPasswordChecking = setupStatus?.strict_password_checking ?? true; + const newPasswordValidation = validatePasswordField(newPassword, t, strictPasswordChecking, true); const isPasswordChangeAttempted = newPassword.length > 0 || currentPassword.length > 0; const passwordsMatch = newPassword.length > 0 && newPassword === confirmPassword; @@ -305,6 +293,21 @@ export const UserProfile = memo(() => { {newPassword.length > 0 && !newPasswordValidation.isValid && ( {newPasswordValidation.message} )} + {newPassword.length > 0 && newPasswordValidation.isValid && newPasswordValidation.message && ( + + {newPasswordValidation.message} + + )} diff --git a/invokeai/frontend/web/src/features/auth/util/passwordUtils.ts b/invokeai/frontend/web/src/features/auth/util/passwordUtils.ts new file mode 100644 index 00000000000..53200d2c65f --- /dev/null +++ b/invokeai/frontend/web/src/features/auth/util/passwordUtils.ts @@ -0,0 +1,70 @@ +export type PasswordStrength = 'weak' | 'moderate' | 'strong'; + +export type PasswordValidationResult = { + isValid: boolean; + message: string; + strength: PasswordStrength | null; +}; + +/** + * Returns the strength level of a password. + * - weak: less than 8 characters + * - moderate: 8+ characters but missing uppercase, lowercase, or digit + * - strong: 8+ characters with uppercase, lowercase, and digit + */ +export const getPasswordStrength = (password: string): PasswordStrength => { + if (password.length < 8) { + return 'weak'; + } + const hasUpper = /[A-Z]/.test(password); + const hasLower = /[a-z]/.test(password); + const hasDigit = /\d/.test(password); + if (!hasUpper || !hasLower || !hasDigit) { + return 'moderate'; + } + return 'strong'; +}; + +/** + * Validates a password field. + * + * In strict mode, passwords must be 8+ characters with uppercase, lowercase, and digits. + * In non-strict mode, any non-empty password is accepted but strength is reported. + * + * @param password - The password to validate + * @param t - Translation function + * @param strictPasswordChecking - Whether to enforce strict requirements + * @param allowEmpty - When true, an empty string is treated as "no change" (valid with no message) + */ +export const validatePasswordField = ( + password: string, + t: (key: string) => string, + strictPasswordChecking: boolean, + allowEmpty = false +): PasswordValidationResult => { + if (password.length === 0) { + return { isValid: allowEmpty, message: '', strength: null }; + } + + const strength = getPasswordStrength(password); + + if (!strictPasswordChecking) { + return { + isValid: true, + message: t(`auth.passwordStrength.${strength}`), + strength, + }; + } + + // Strict mode + if (password.length < 8) { + return { isValid: false, message: t('auth.setup.passwordTooShort'), strength }; + } + const hasUpper = /[A-Z]/.test(password); + const hasLower = /[a-z]/.test(password); + const hasDigit = /\d/.test(password); + if (!hasUpper || !hasLower || !hasDigit) { + return { isValid: false, message: t('auth.setup.passwordMissingRequirements'), strength }; + } + return { isValid: true, message: '', strength }; +}; diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.ts index c7a8a8b1ffc..419e7c730ce 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/auth.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/auth.ts @@ -33,6 +33,7 @@ type LogoutResponse = { type SetupStatusResponse = { setup_required: boolean; multiuser_enabled: boolean; + strict_password_checking: boolean; }; export type UserDTO = components['schemas']['UserDTO']; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 52a318f8160..10ad54ffbd6 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -14375,6 +14375,7 @@ export type components = { * unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production. * allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation. * multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization. + * strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength is reported to the user. */ InvokeAIAppConfig: { /** @@ -14748,6 +14749,12 @@ export type components = { * @default false */ multiuser?: boolean; + /** + * Strict Password Checking + * @description Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user. + * @default false + */ + strict_password_checking?: boolean; }; /** * InvokeAIAppConfigWithSetFields @@ -24486,6 +24493,11 @@ export type components = { * @description Whether multiuser mode is enabled */ multiuser_enabled: boolean; + /** + * Strict Password Checking + * @description Whether strict password requirements are enforced + */ + strict_password_checking: boolean; }; /** * Show Image diff --git a/tests/app/services/auth/test_password_utils.py b/tests/app/services/auth/test_password_utils.py index 64fdeb9d424..82b1c435ef0 100644 --- a/tests/app/services/auth/test_password_utils.py +++ b/tests/app/services/auth/test_password_utils.py @@ -1,6 +1,11 @@ """Unit tests for password utilities.""" -from invokeai.app.services.auth.password_utils import hash_password, validate_password_strength, verify_password +from invokeai.app.services.auth.password_utils import ( + get_password_strength, + hash_password, + validate_password_strength, + verify_password, +) class TestPasswordHashing: @@ -223,6 +228,58 @@ def test_validate_password_very_long(self): assert message == "" +class TestGetPasswordStrength: + """Tests for get_password_strength function.""" + + def test_weak_password_too_short(self): + """Test that passwords shorter than 8 characters are 'weak'.""" + assert get_password_strength("Ab1") == "weak" + assert get_password_strength("Ab1defg") == "weak" # 7 chars + assert get_password_strength("") == "weak" + + def test_moderate_password_missing_uppercase(self): + """Test that 8+ char passwords missing uppercase are 'moderate'.""" + assert get_password_strength("lowercase1") == "moderate" + + def test_moderate_password_missing_lowercase(self): + """Test that 8+ char passwords missing lowercase are 'moderate'.""" + assert get_password_strength("UPPERCASE1") == "moderate" + + def test_moderate_password_missing_digit(self): + """Test that 8+ char passwords missing digits are 'moderate'.""" + assert get_password_strength("NoDigitsHere") == "moderate" + + def test_moderate_password_only_lowercase_and_digit(self): + """Test that 8+ char passwords with only lowercase and digit are 'moderate'.""" + assert get_password_strength("lowercase1") == "moderate" + + def test_strong_password(self): + """Test that 8+ char passwords with upper, lower, and digit are 'strong'.""" + assert get_password_strength("StrongPass1") == "strong" + assert get_password_strength("Pass123A") == "strong" + + def test_strong_password_with_special_chars(self): + """Test that passwords meeting all requirements plus special chars are 'strong'.""" + assert get_password_strength("Pass!@#$123") == "strong" + + def test_exactly_8_characters_meeting_requirements(self): + """Test that exactly 8 characters meeting requirements is 'strong'.""" + assert get_password_strength("Pass123A") == "strong" + + def test_exactly_8_characters_missing_uppercase(self): + """Test that exactly 8 characters missing uppercase is 'moderate'.""" + assert get_password_strength("pass123a") == "moderate" + + def test_strength_progression(self): + """Test that strength improves as requirements are met.""" + # Too short - weak + assert get_password_strength("Abc1") == "weak" + # Long enough but only lowercase - moderate + assert get_password_strength("abcdefgh") == "moderate" + # Meets all requirements - strong + assert get_password_strength("Abcdefg1") == "strong" + + class TestPasswordSecurityProperties: """Tests for security properties of password handling.""" From 035745b8f03164f3a2dc81d0e2728bc7d48e7c9c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:38:21 +0000 Subject: [PATCH 2/4] Changes before error encountered Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- tests/app/routers/test_auth.py | 22 ++++++++++++++++++- tests/app/services/users/test_user_service.py | 17 ++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/tests/app/routers/test_auth.py b/tests/app/routers/test_auth.py index 0949048e607..5362bd775ff 100644 --- a/tests/app/routers/test_auth.py +++ b/tests/app/routers/test_auth.py @@ -300,8 +300,9 @@ def test_setup_admin_already_exists(monkeypatch: Any, mock_invoker: Invoker, cli def test_setup_admin_weak_password(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: - """Test setup fails with weak password.""" + """Test setup fails with weak password when strict password checking is enabled.""" monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + mock_invoker.services.configuration.strict_password_checking = True response = client.post( "/api/v1/auth/setup", @@ -316,6 +317,25 @@ def test_setup_admin_weak_password(monkeypatch: Any, mock_invoker: Invoker, clie assert "Password" in response.json()["detail"] +def test_setup_admin_weak_password_non_strict(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test setup succeeds with weak password when strict password checking is disabled (the default).""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + mock_invoker.services.configuration.strict_password_checking = False + + response = client.post( + "/api/v1/auth/setup", + json={ + "email": "admin3b@example.com", + "display_name": "Admin User", + "password": "weak", + }, + ) + + assert response.status_code == 200 + json_response = response.json() + assert json_response["success"] is True + + def test_admin_user_token_has_admin_flag(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: """Test that admin user login returns token with admin flag.""" monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) diff --git a/tests/app/services/users/test_user_service.py b/tests/app/services/users/test_user_service.py index 479c911a0da..d5d04964005 100644 --- a/tests/app/services/users/test_user_service.py +++ b/tests/app/services/users/test_user_service.py @@ -62,7 +62,7 @@ def test_create_user(user_service: UserService): def test_create_user_weak_password(user_service: UserService): - """Test creating a user with weak password.""" + """Test creating a user with weak password fails when strict checking is enabled.""" user_data = UserCreateRequest( email="test@example.com", display_name="Test User", @@ -71,7 +71,20 @@ def test_create_user_weak_password(user_service: UserService): ) with pytest.raises(ValueError, match="at least 8 characters"): - user_service.create(user_data) + user_service.create(user_data, strict_password_checking=True) + + +def test_create_user_weak_password_non_strict(user_service: UserService): + """Test creating a user with weak password succeeds when strict checking is disabled.""" + user_data = UserCreateRequest( + email="weakpass@example.com", + display_name="Test User", + password="weak", + is_admin=False, + ) + + user = user_service.create(user_data, strict_password_checking=False) + assert user.email == "weakpass@example.com" def test_create_duplicate_user(user_service: UserService): From 06bf950bad3fc1a2071f176d63d2353e88d2f6b1 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 9 Mar 2026 23:48:43 -0400 Subject: [PATCH 3/4] chore(backend): docstrings --- invokeai/app/services/config/config_default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index dc8186f2a33..5d1b1d0d8d5 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -111,7 +111,7 @@ class InvokeAIAppConfig(BaseSettings): unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production. allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation. multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization. - strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength is reported to the user. + strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user. """ _root: Optional[Path] = PrivateAttr(default=None) From a4091738cc1d1fbdb69a578d7a96d668d4047b56 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 9 Mar 2026 23:52:34 -0400 Subject: [PATCH 4/4] chore(frontend): typegen --- invokeai/frontend/web/src/services/api/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 10ad54ffbd6..2f6af1ee2e5 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -14375,7 +14375,7 @@ export type components = { * unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production. * allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation. * multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization. - * strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength is reported to the user. + * strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user. */ InvokeAIAppConfig: { /**