Skip to content
Open
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
22 changes: 16 additions & 6 deletions invokeai/app/api/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -359,14 +364,15 @@ 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,
display_name=request.display_name,
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

Expand Down Expand Up @@ -414,14 +420,15 @@ 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,
password=request.password,
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

Expand Down Expand Up @@ -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:
Expand All @@ -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
29 changes: 28 additions & 1 deletion invokeai/app/services/auth/password_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Password hashing and validation utilities."""

from typing import cast
from typing import Literal, cast

from passlib.context import CryptContext

Expand Down Expand Up @@ -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"
2 changes: 2 additions & 0 deletions invokeai/app/services/config/config_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (weak/moderate/strong) is reported to the user.
"""

_root: Optional[Path] = PrivateAttr(default=None)
Expand Down Expand Up @@ -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

Expand Down
18 changes: 12 additions & 6 deletions invokeai/app/services/users/users_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
26 changes: 16 additions & 10 deletions invokeai/app/services/users/users_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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] = []
Expand Down Expand Up @@ -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")
Expand All @@ -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."""
Expand Down
8 changes: 7 additions & 1 deletion invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(
Expand Down Expand Up @@ -120,6 +100,13 @@ export const AdministratorSetup = memo(() => {
);
}

const passwordStrengthColor =
passwordValidation.strength === 'weak'
? 'error.300'
: passwordValidation.strength === 'moderate'
? 'warning.300'
: 'invokeBlue.300';

return (
<Center w="100dvw" h="100dvh" bg="base.900">
<Box w="full" maxW="600px" p={8} borderRadius="lg" bg="base.800" boxShadow="dark-lg">
Expand Down Expand Up @@ -192,7 +179,16 @@ export const AdministratorSetup = memo(() => {
{password.length > 0 && !passwordValidation.isValid && (
<FormErrorMessage>{passwordValidation.message}</FormErrorMessage>
)}
{password.length === 0 && <FormHelperText mt={1}>{t('auth.setup.passwordHelper')}</FormHelperText>}
{password.length > 0 && passwordValidation.isValid && passwordValidation.message && (
<Text mt={1} fontSize="sm" color={passwordStrengthColor}>
{passwordValidation.message}
</Text>
)}
{password.length === 0 && (
<FormHelperText mt={1}>
{strictPasswordChecking ? t('auth.setup.passwordHelper') : t('auth.setup.passwordHelperRelaxed')}
</FormHelperText>
)}
</GridItem>
</Grid>
</FormControl>
Expand Down
Loading