Skip to content

Commit cd47b3b

Browse files
lsteinCopilotJPPhoto
authored
Feature: Make strict password checking optional (invoke-ai#8957)
* 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> * Changes before error encountered Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(backend): docstrings * chore(frontend): typegen --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Jonathan <34005131+JPPhoto@users.noreply.github.com>
1 parent c8ac303 commit cd47b3b

File tree

15 files changed

+323
-93
lines changed

15 files changed

+323
-93
lines changed

invokeai/app/api/routers/auth.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class SetupStatusResponse(BaseModel):
7979

8080
setup_required: bool = Field(description="Whether initial setup is required")
8181
multiuser_enabled: bool = Field(description="Whether multiuser mode is enabled")
82+
strict_password_checking: bool = Field(description="Whether strict password requirements are enforced")
8283

8384

8485
@auth_router.get("/status", response_model=SetupStatusResponse)
@@ -92,13 +93,17 @@ async def get_setup_status() -> SetupStatusResponse:
9293

9394
# If multiuser is disabled, setup is never required
9495
if not config.multiuser:
95-
return SetupStatusResponse(setup_required=False, multiuser_enabled=False)
96+
return SetupStatusResponse(
97+
setup_required=False, multiuser_enabled=False, strict_password_checking=config.strict_password_checking
98+
)
9699

97100
# In multiuser mode, check if an admin exists
98101
user_service = ApiDependencies.invoker.services.users
99102
setup_required = not user_service.has_admin()
100103

101-
return SetupStatusResponse(setup_required=setup_required, multiuser_enabled=True)
104+
return SetupStatusResponse(
105+
setup_required=setup_required, multiuser_enabled=True, strict_password_checking=config.strict_password_checking
106+
)
102107

103108

104109
@auth_router.post("/login", response_model=LoginResponse)
@@ -248,7 +253,7 @@ async def setup_admin(
248253
password=request.password,
249254
is_admin=True,
250255
)
251-
user = user_service.create_admin(user_data)
256+
user = user_service.create_admin(user_data, strict_password_checking=config.strict_password_checking)
252257
except ValueError as e:
253258
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
254259

@@ -359,14 +364,15 @@ async def create_user(
359364
HTTPException: 400 if email already exists or password is weak
360365
"""
361366
user_service = ApiDependencies.invoker.services.users
367+
config = ApiDependencies.invoker.services.configuration
362368
try:
363369
user_data = UserCreateRequest(
364370
email=request.email,
365371
display_name=request.display_name,
366372
password=request.password,
367373
is_admin=request.is_admin,
368374
)
369-
return user_service.create(user_data)
375+
return user_service.create(user_data, strict_password_checking=config.strict_password_checking)
370376
except ValueError as e:
371377
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
372378

@@ -414,14 +420,15 @@ async def update_user(
414420
HTTPException: 404 if user not found
415421
"""
416422
user_service = ApiDependencies.invoker.services.users
423+
config = ApiDependencies.invoker.services.configuration
417424
try:
418425
changes = UserUpdateRequest(
419426
display_name=request.display_name,
420427
password=request.password,
421428
is_admin=request.is_admin,
422429
is_active=request.is_active,
423430
)
424-
return user_service.update(user_id, changes)
431+
return user_service.update(user_id, changes, strict_password_checking=config.strict_password_checking)
425432
except ValueError as e:
426433
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
427434

@@ -483,6 +490,7 @@ async def update_current_user(
483490
HTTPException: 404 if user not found
484491
"""
485492
user_service = ApiDependencies.invoker.services.users
493+
config = ApiDependencies.invoker.services.configuration
486494

487495
# Verify current password when attempting a password change
488496
if request.new_password is not None:
@@ -509,6 +517,8 @@ async def update_current_user(
509517
display_name=request.display_name,
510518
password=request.new_password,
511519
)
512-
return user_service.update(current_user.user_id, changes)
520+
return user_service.update(
521+
current_user.user_id, changes, strict_password_checking=config.strict_password_checking
522+
)
513523
except ValueError as e:
514524
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e

invokeai/app/services/auth/password_utils.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Password hashing and validation utilities."""
22

3-
from typing import cast
3+
from typing import Literal, cast
44

55
from passlib.context import CryptContext
66

@@ -84,3 +84,30 @@ def validate_password_strength(password: str) -> tuple[bool, str]:
8484
return False, "Password must contain uppercase, lowercase, and numbers"
8585

8686
return True, ""
87+
88+
89+
def get_password_strength(password: str) -> Literal["weak", "moderate", "strong"]:
90+
"""Determine the strength of a password.
91+
92+
Strength levels:
93+
- weak: less than 8 characters
94+
- moderate: 8+ characters but missing at least one of uppercase, lowercase, or digit
95+
- strong: 8+ characters with uppercase, lowercase, and digit
96+
97+
Args:
98+
password: The password to evaluate
99+
100+
Returns:
101+
One of "weak", "moderate", or "strong"
102+
"""
103+
if len(password) < 8:
104+
return "weak"
105+
106+
has_upper = any(c.isupper() for c in password)
107+
has_lower = any(c.islower() for c in password)
108+
has_digit = any(c.isdigit() for c in password)
109+
110+
if not (has_upper and has_lower and has_digit):
111+
return "moderate"
112+
113+
return "strong"

invokeai/app/services/config/config_default.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ class InvokeAIAppConfig(BaseSettings):
111111
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.
112112
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.
113113
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.
114+
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.
114115
"""
115116

116117
_root: Optional[Path] = PrivateAttr(default=None)
@@ -206,6 +207,7 @@ class InvokeAIAppConfig(BaseSettings):
206207

207208
# MULTIUSER
208209
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.")
210+
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.")
209211

210212
# fmt: on
211213

invokeai/app/services/users/users_base.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,19 @@ class UserServiceBase(ABC):
99
"""High-level service for user management."""
1010

1111
@abstractmethod
12-
def create(self, user_data: UserCreateRequest) -> UserDTO:
12+
def create(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
1313
"""Create a new user.
1414
1515
Args:
1616
user_data: User creation data
17+
strict_password_checking: If True (default), passwords must meet strength requirements.
18+
If False, any non-empty password is accepted.
1719
1820
Returns:
1921
The created user
2022
2123
Raises:
22-
ValueError: If email already exists or password is weak
24+
ValueError: If email already exists or (when strict) password is weak
2325
"""
2426
pass
2527

@@ -48,18 +50,20 @@ def get_by_email(self, email: str) -> UserDTO | None:
4850
pass
4951

5052
@abstractmethod
51-
def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO:
53+
def update(self, user_id: str, changes: UserUpdateRequest, strict_password_checking: bool = True) -> UserDTO:
5254
"""Update user.
5355
5456
Args:
5557
user_id: The user ID
5658
changes: Fields to update
59+
strict_password_checking: If True (default), passwords must meet strength requirements.
60+
If False, any non-empty password is accepted.
5761
5862
Returns:
5963
The updated user
6064
6165
Raises:
62-
ValueError: If user not found or password is weak
66+
ValueError: If user not found or (when strict) password is weak
6367
"""
6468
pass
6569

@@ -98,17 +102,19 @@ def has_admin(self) -> bool:
98102
pass
99103

100104
@abstractmethod
101-
def create_admin(self, user_data: UserCreateRequest) -> UserDTO:
105+
def create_admin(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
102106
"""Create an admin user (for initial setup).
103107
104108
Args:
105109
user_data: User creation data
110+
strict_password_checking: If True (default), passwords must meet strength requirements.
111+
If False, any non-empty password is accepted.
106112
107113
Returns:
108114
The created admin user
109115
110116
Raises:
111-
ValueError: If admin already exists or password is weak
117+
ValueError: If admin already exists or (when strict) password is weak
112118
"""
113119
pass
114120

invokeai/app/services/users/users_default.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ def __init__(self, db: SqliteDatabase):
2121
"""
2222
self._db = db
2323

24-
def create(self, user_data: UserCreateRequest) -> UserDTO:
24+
def create(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
2525
"""Create a new user."""
2626
# Validate password strength
27-
is_valid, error_msg = validate_password_strength(user_data.password)
28-
if not is_valid:
29-
raise ValueError(error_msg)
27+
if strict_password_checking:
28+
is_valid, error_msg = validate_password_strength(user_data.password)
29+
if not is_valid:
30+
raise ValueError(error_msg)
31+
elif not user_data.password:
32+
raise ValueError("Password cannot be empty")
3033

3134
# Check if email already exists
3235
if self.get_by_email(user_data.email) is not None:
@@ -106,7 +109,7 @@ def get_by_email(self, email: str) -> UserDTO | None:
106109
last_login_at=datetime.fromisoformat(row[7]) if row[7] else None,
107110
)
108111

109-
def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO:
112+
def update(self, user_id: str, changes: UserUpdateRequest, strict_password_checking: bool = True) -> UserDTO:
110113
"""Update user."""
111114
# Check if user exists
112115
user = self.get(user_id)
@@ -115,9 +118,12 @@ def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO:
115118

116119
# Validate password if provided
117120
if changes.password is not None:
118-
is_valid, error_msg = validate_password_strength(changes.password)
119-
if not is_valid:
120-
raise ValueError(error_msg)
121+
if strict_password_checking:
122+
is_valid, error_msg = validate_password_strength(changes.password)
123+
if not is_valid:
124+
raise ValueError(error_msg)
125+
elif not changes.password:
126+
raise ValueError("Password cannot be empty")
121127

122128
# Build update query dynamically based on provided fields
123129
updates: list[str] = []
@@ -208,7 +214,7 @@ def has_admin(self) -> bool:
208214
count = row[0] if row else 0
209215
return bool(count > 0)
210216

211-
def create_admin(self, user_data: UserCreateRequest) -> UserDTO:
217+
def create_admin(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
212218
"""Create an admin user (for initial setup)."""
213219
if self.has_admin():
214220
raise ValueError("Admin user already exists")
@@ -220,7 +226,7 @@ def create_admin(self, user_data: UserCreateRequest) -> UserDTO:
220226
password=user_data.password,
221227
is_admin=True,
222228
)
223-
return self.create(admin_data)
229+
return self.create(admin_data, strict_password_checking=strict_password_checking)
224230

225231
def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
226232
"""List all users."""

invokeai/frontend/web/public/locales/en.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
"passwordsDoNotMatch": "Passwords do not match",
4747
"createAccount": "Create Administrator Account",
4848
"creatingAccount": "Setting up...",
49-
"setupFailed": "Setup failed. Please try again."
49+
"setupFailed": "Setup failed. Please try again.",
50+
"passwordHelperRelaxed": "Enter any password (strength will be shown)"
5051
},
5152
"userMenu": "User Menu",
5253
"admin": "Admin",
@@ -102,6 +103,11 @@
102103
"back": "Back",
103104
"cannotDeleteSelf": "You cannot delete your own account",
104105
"cannotDeactivateSelf": "You cannot deactivate your own account"
106+
},
107+
"passwordStrength": {
108+
"weak": "Weak password",
109+
"moderate": "Moderate password",
110+
"strong": "Strong password"
105111
}
106112
},
107113
"boards": {

invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,34 +15,13 @@ import {
1515
Text,
1616
VStack,
1717
} from '@invoke-ai/ui-library';
18+
import { validatePasswordField } from 'features/auth/util/passwordUtils';
1819
import type { ChangeEvent, FormEvent } from 'react';
1920
import { memo, useCallback, useEffect, useState } from 'react';
2021
import { useTranslation } from 'react-i18next';
2122
import { useNavigate } from 'react-router-dom';
2223
import { useGetSetupStatusQuery, useSetupMutation } from 'services/api/endpoints/auth';
2324

24-
const validatePasswordStrength = (
25-
password: string,
26-
t: (key: string) => string
27-
): { isValid: boolean; message: string } => {
28-
if (password.length < 8) {
29-
return { isValid: false, message: t('auth.setup.passwordTooShort') };
30-
}
31-
32-
const hasUpper = /[A-Z]/.test(password);
33-
const hasLower = /[a-z]/.test(password);
34-
const hasDigit = /\d/.test(password);
35-
36-
if (!hasUpper || !hasLower || !hasDigit) {
37-
return {
38-
isValid: false,
39-
message: t('auth.setup.passwordMissingRequirements'),
40-
};
41-
}
42-
43-
return { isValid: true, message: '' };
44-
};
45-
4625
export const AdministratorSetup = memo(() => {
4726
const { t } = useTranslation();
4827
const navigate = useNavigate();
@@ -60,7 +39,8 @@ export const AdministratorSetup = memo(() => {
6039
}
6140
}, [setupStatus, isLoadingSetup, navigate]);
6241

63-
const passwordValidation = validatePasswordStrength(password, t);
42+
const strictPasswordChecking = setupStatus?.strict_password_checking ?? true;
43+
const passwordValidation = validatePasswordField(password, t, strictPasswordChecking, false);
6444
const passwordsMatch = password === confirmPassword;
6545

6646
const handleSubmit = useCallback(
@@ -120,6 +100,13 @@ export const AdministratorSetup = memo(() => {
120100
);
121101
}
122102

103+
const passwordStrengthColor =
104+
passwordValidation.strength === 'weak'
105+
? 'error.300'
106+
: passwordValidation.strength === 'moderate'
107+
? 'warning.300'
108+
: 'invokeBlue.300';
109+
123110
return (
124111
<Center w="100dvw" h="100dvh" bg="base.900">
125112
<Box w="full" maxW="600px" p={8} borderRadius="lg" bg="base.800" boxShadow="dark-lg">
@@ -192,7 +179,16 @@ export const AdministratorSetup = memo(() => {
192179
{password.length > 0 && !passwordValidation.isValid && (
193180
<FormErrorMessage>{passwordValidation.message}</FormErrorMessage>
194181
)}
195-
{password.length === 0 && <FormHelperText mt={1}>{t('auth.setup.passwordHelper')}</FormHelperText>}
182+
{password.length > 0 && passwordValidation.isValid && passwordValidation.message && (
183+
<Text mt={1} fontSize="sm" color={passwordStrengthColor}>
184+
{passwordValidation.message}
185+
</Text>
186+
)}
187+
{password.length === 0 && (
188+
<FormHelperText mt={1}>
189+
{strictPasswordChecking ? t('auth.setup.passwordHelper') : t('auth.setup.passwordHelperRelaxed')}
190+
</FormHelperText>
191+
)}
196192
</GridItem>
197193
</Grid>
198194
</FormControl>

0 commit comments

Comments
 (0)