diff --git a/invokeai/app/services/auth/__init__.py b/invokeai/app/services/auth/__init__.py new file mode 100644 index 00000000000..099a5e7da1b --- /dev/null +++ b/invokeai/app/services/auth/__init__.py @@ -0,0 +1 @@ +"""Authentication service module.""" diff --git a/invokeai/app/services/auth/password_utils.py b/invokeai/app/services/auth/password_utils.py new file mode 100644 index 00000000000..c76a43444ca --- /dev/null +++ b/invokeai/app/services/auth/password_utils.py @@ -0,0 +1,82 @@ +"""Password hashing and validation utilities.""" + +from typing import cast + +from passlib.context import CryptContext + +# Configure bcrypt context - set truncate_error=False to allow passwords >72 bytes +# without raising an error. They will be automatically truncated by bcrypt to 72 bytes. +pwd_context = CryptContext( + schemes=["bcrypt"], + deprecated="auto", + bcrypt__truncate_error=False, +) + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt. + + bcrypt has a maximum password length of 72 bytes. Longer passwords + are automatically truncated to comply with this limit. + + Args: + password: The plain text password to hash + + Returns: + The hashed password + """ + # bcrypt has a 72 byte limit - encode and truncate if necessary + password_bytes = password.encode("utf-8") + if len(password_bytes) > 72: + # Truncate to 72 bytes and decode back, dropping incomplete UTF-8 sequences + password = password_bytes[:72].decode("utf-8", errors="ignore") + return cast(str, pwd_context.hash(password)) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a hash. + + bcrypt has a maximum password length of 72 bytes. Longer passwords + are automatically truncated to match hash_password behavior. + + Args: + plain_password: The plain text password to verify + hashed_password: The hashed password to verify against + + Returns: + True if the password matches the hash, False otherwise + """ + # bcrypt has a 72 byte limit - encode and truncate if necessary to match hash_password + password_bytes = plain_password.encode("utf-8") + if len(password_bytes) > 72: + # Truncate to 72 bytes and decode back, dropping incomplete UTF-8 sequences + plain_password = password_bytes[:72].decode("utf-8", errors="ignore") + return cast(bool, pwd_context.verify(plain_password, hashed_password)) + + +def validate_password_strength(password: str) -> tuple[bool, str]: + """Validate password meets minimum security requirements. + + Password requirements: + - At least 8 characters long + - Contains at least one uppercase letter + - Contains at least one lowercase letter + - Contains at least one digit + + Args: + password: The password to validate + + Returns: + A tuple of (is_valid, error_message). If valid, error_message is empty. + """ + if len(password) < 8: + return False, "Password must be at least 8 characters long" + + 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 False, "Password must contain uppercase, lowercase, and numbers" + + return True, "" diff --git a/invokeai/app/services/auth/token_service.py b/invokeai/app/services/auth/token_service.py new file mode 100644 index 00000000000..7c275714ee5 --- /dev/null +++ b/invokeai/app/services/auth/token_service.py @@ -0,0 +1,58 @@ +"""JWT token generation and validation.""" + +from datetime import datetime, timedelta, timezone +from typing import cast + +from jose import JWTError, jwt +from pydantic import BaseModel + +# SECURITY WARNING: This is a placeholder secret key for development only. +# In production, this MUST be: +# 1. Generated using a cryptographically secure random generator +# 2. Stored in environment variables or secure configuration +# 3. Never committed to source control +# 4. Rotated periodically +# TODO: Move to config system - see invokeai.app.services.config.config_default +SECRET_KEY = "your-secret-key-should-be-in-config-change-this-in-production" +ALGORITHM = "HS256" +DEFAULT_EXPIRATION_HOURS = 24 + + +class TokenData(BaseModel): + """Data stored in JWT token.""" + + user_id: str + email: str + is_admin: bool + + +def create_access_token(data: TokenData, expires_delta: timedelta | None = None) -> str: + """Create a JWT access token. + + Args: + data: The token data to encode + expires_delta: Optional expiration time delta. Defaults to 24 hours. + + Returns: + The encoded JWT token + """ + to_encode = data.model_dump() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(hours=DEFAULT_EXPIRATION_HOURS)) + to_encode.update({"exp": expire}) + return cast(str, jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)) + + +def verify_token(token: str) -> TokenData | None: + """Verify and decode a JWT token. + + Args: + token: The JWT token to verify + + Returns: + TokenData if valid, None if invalid or expired + """ + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return TokenData(**payload) + except JWTError: + return None diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py index df0e5fca049..54a0450084a 100644 --- a/invokeai/app/services/shared/sqlite/sqlite_util.py +++ b/invokeai/app/services/shared/sqlite/sqlite_util.py @@ -27,6 +27,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_22 import build_migration_22 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_23 import build_migration_23 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_24 import build_migration_24 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_25 import build_migration_25 from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator @@ -71,6 +72,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto migrator.register_migration(build_migration_22(app_config=config, logger=logger)) migrator.register_migration(build_migration_23(app_config=config, logger=logger)) migrator.register_migration(build_migration_24(app_config=config, logger=logger)) + migrator.register_migration(build_migration_25()) migrator.run_migrations() return db diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_25.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_25.py new file mode 100644 index 00000000000..527e4ec2c84 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_25.py @@ -0,0 +1,217 @@ +"""Migration 25: Add multi-user support. + +This migration adds the database schema for multi-user support, including: +- users table for user accounts +- user_sessions table for session management +- user_invitations table for invitation system +- shared_boards table for board sharing +- Adding user_id columns to existing tables for data ownership +""" + +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration25Callback: + """Migration to add multi-user support.""" + + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._create_users_table(cursor) + self._create_user_sessions_table(cursor) + self._create_user_invitations_table(cursor) + self._create_shared_boards_table(cursor) + self._update_boards_table(cursor) + self._update_images_table(cursor) + self._update_workflows_table(cursor) + self._update_session_queue_table(cursor) + self._update_style_presets_table(cursor) + self._create_system_user(cursor) + + def _create_users_table(self, cursor: sqlite3.Cursor) -> None: + """Create users table.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + password_hash TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + last_login_at DATETIME + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_admin ON users(is_admin);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);") + + cursor.execute(""" + CREATE TRIGGER IF NOT EXISTS tg_users_updated_at + AFTER UPDATE ON users FOR EACH ROW + BEGIN + UPDATE users SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE user_id = old.user_id; + END; + """) + + def _create_user_sessions_table(self, cursor: sqlite3.Cursor) -> None: + """Create user_sessions table for session management.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_sessions ( + session_id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + last_activity_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_token_hash ON user_sessions(token_hash);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at);") + + def _create_user_invitations_table(self, cursor: sqlite3.Cursor) -> None: + """Create user_invitations table for invitation system.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_invitations ( + invitation_id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL, + invited_by TEXT NOT NULL, + invitation_code TEXT NOT NULL UNIQUE, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + expires_at DATETIME NOT NULL, + used_at DATETIME, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + FOREIGN KEY (invited_by) REFERENCES users(user_id) ON DELETE CASCADE + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_email ON user_invitations(email);") + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_user_invitations_invitation_code ON user_invitations(invitation_code);" + ) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_expires_at ON user_invitations(expires_at);") + + def _create_shared_boards_table(self, cursor: sqlite3.Cursor) -> None: + """Create shared_boards table for board sharing.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS shared_boards ( + board_id TEXT NOT NULL, + user_id TEXT NOT NULL, + can_edit BOOLEAN NOT NULL DEFAULT FALSE, + shared_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + PRIMARY KEY (board_id, user_id), + FOREIGN KEY (board_id) REFERENCES boards(board_id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_user_id ON shared_boards(user_id);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_board_id ON shared_boards(board_id);") + + def _update_boards_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id and is_public columns to boards table.""" + # Check if boards table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';") + if cursor.fetchone() is None: + return + + # Check if user_id column exists + cursor.execute("PRAGMA table_info(boards);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE boards ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_user_id ON boards(user_id);") + + if "is_public" not in columns: + cursor.execute("ALTER TABLE boards ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_is_public ON boards(is_public);") + + def _update_images_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id column to images table.""" + # Check if images table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='images';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(images);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE images ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id);") + + def _update_workflows_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id and is_public columns to workflows table.""" + # Check if workflows table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='workflows';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(workflows);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE workflows ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_user_id ON workflows(user_id);") + + if "is_public" not in columns: + cursor.execute("ALTER TABLE workflows ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_is_public ON workflows(is_public);") + + def _update_session_queue_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id column to session_queue table.""" + # Check if session_queue table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(session_queue);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE session_queue ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_queue_user_id ON session_queue(user_id);") + + def _update_style_presets_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id and is_public columns to style_presets table.""" + # Check if style_presets table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='style_presets';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(style_presets);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE style_presets ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_user_id ON style_presets(user_id);") + + if "is_public" not in columns: + cursor.execute("ALTER TABLE style_presets ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_is_public ON style_presets(is_public);") + + def _create_system_user(self, cursor: sqlite3.Cursor) -> None: + """Create system user for backward compatibility.""" + cursor.execute(""" + INSERT OR IGNORE INTO users (user_id, email, display_name, password_hash, is_admin, is_active) + VALUES ('system', 'system@system.invokeai', 'System', '', TRUE, TRUE); + """) + + +def build_migration_25() -> Migration: + """Builds the migration object for migrating from version 24 to version 25. + + This migration adds multi-user support to the database schema. + """ + return Migration( + from_version=24, + to_version=25, + callback=Migration25Callback(), + ) diff --git a/invokeai/app/services/users/__init__.py b/invokeai/app/services/users/__init__.py new file mode 100644 index 00000000000..f4976759504 --- /dev/null +++ b/invokeai/app/services/users/__init__.py @@ -0,0 +1 @@ +"""User service module.""" diff --git a/invokeai/app/services/users/users_base.py b/invokeai/app/services/users/users_base.py new file mode 100644 index 00000000000..6587a2aa3ae --- /dev/null +++ b/invokeai/app/services/users/users_base.py @@ -0,0 +1,126 @@ +"""Abstract base class for user service.""" + +from abc import ABC, abstractmethod + +from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, UserUpdateRequest + + +class UserServiceBase(ABC): + """High-level service for user management.""" + + @abstractmethod + def create(self, user_data: UserCreateRequest) -> UserDTO: + """Create a new user. + + Args: + user_data: User creation data + + Returns: + The created user + + Raises: + ValueError: If email already exists or password is weak + """ + pass + + @abstractmethod + def get(self, user_id: str) -> UserDTO | None: + """Get user by ID. + + Args: + user_id: The user ID + + Returns: + UserDTO if found, None otherwise + """ + pass + + @abstractmethod + def get_by_email(self, email: str) -> UserDTO | None: + """Get user by email. + + Args: + email: The email address + + Returns: + UserDTO if found, None otherwise + """ + pass + + @abstractmethod + def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO: + """Update user. + + Args: + user_id: The user ID + changes: Fields to update + + Returns: + The updated user + + Raises: + ValueError: If user not found or password is weak + """ + pass + + @abstractmethod + def delete(self, user_id: str) -> None: + """Delete user. + + Args: + user_id: The user ID + + Raises: + ValueError: If user not found + """ + pass + + @abstractmethod + def authenticate(self, email: str, password: str) -> UserDTO | None: + """Authenticate user credentials. + + Args: + email: User email + password: User password + + Returns: + UserDTO if authentication successful, None otherwise + """ + pass + + @abstractmethod + def has_admin(self) -> bool: + """Check if any admin user exists. + + Returns: + True if at least one admin user exists, False otherwise + """ + pass + + @abstractmethod + def create_admin(self, user_data: UserCreateRequest) -> UserDTO: + """Create an admin user (for initial setup). + + Args: + user_data: User creation data + + Returns: + The created admin user + + Raises: + ValueError: If admin already exists or password is weak + """ + pass + + @abstractmethod + def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]: + """List all users. + + Args: + limit: Maximum number of users to return + offset: Number of users to skip + + Returns: + List of users + """ + pass diff --git a/invokeai/app/services/users/users_common.py b/invokeai/app/services/users/users_common.py new file mode 100644 index 00000000000..50d50f6cadd --- /dev/null +++ b/invokeai/app/services/users/users_common.py @@ -0,0 +1,36 @@ +"""Common types and data models for user service.""" + +from datetime import datetime + +from pydantic import BaseModel, EmailStr, Field + + +class UserDTO(BaseModel): + """User data transfer object.""" + + user_id: str = Field(description="Unique user identifier") + email: EmailStr = Field(description="User email address") + display_name: str | None = Field(default=None, description="Display name") + is_admin: bool = Field(default=False, description="Whether user has admin privileges") + is_active: bool = Field(default=True, description="Whether user account is active") + created_at: datetime = Field(description="When the user was created") + updated_at: datetime = Field(description="When the user was last updated") + last_login_at: datetime | None = Field(default=None, description="When user last logged in") + + +class UserCreateRequest(BaseModel): + """Request to create a new user.""" + + email: EmailStr = Field(description="User email address") + display_name: str | None = Field(default=None, description="Display name") + password: str = Field(description="User password") + is_admin: bool = Field(default=False, description="Whether user should have admin privileges") + + +class UserUpdateRequest(BaseModel): + """Request to update a user.""" + + display_name: str | None = Field(default=None, description="Display name") + password: str | None = Field(default=None, description="New password") + is_admin: bool | None = Field(default=None, description="Whether user should have admin privileges") + is_active: bool | None = Field(default=None, description="Whether user account should be active") diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py new file mode 100644 index 00000000000..36ccec9e7e2 --- /dev/null +++ b/invokeai/app/services/users/users_default.py @@ -0,0 +1,251 @@ +"""Default SQLite implementation of user service.""" + +import sqlite3 +from datetime import datetime, timezone +from uuid import uuid4 + +from invokeai.app.services.auth.password_utils import hash_password, validate_password_strength, verify_password +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.services.users.users_base import UserServiceBase +from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, UserUpdateRequest + + +class UserService(UserServiceBase): + """SQLite-based user service.""" + + def __init__(self, db: SqliteDatabase): + """Initialize user service. + + Args: + db: SQLite database instance + """ + self._db = db + + def create(self, user_data: UserCreateRequest) -> 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) + + # Check if email already exists + if self.get_by_email(user_data.email) is not None: + raise ValueError(f"User with email {user_data.email} already exists") + + user_id = str(uuid4()) + password_hash = hash_password(user_data.password) + + with self._db.transaction() as cursor: + try: + cursor.execute( + """ + INSERT INTO users (user_id, email, display_name, password_hash, is_admin) + VALUES (?, ?, ?, ?, ?) + """, + (user_id, user_data.email, user_data.display_name, password_hash, user_data.is_admin), + ) + except sqlite3.IntegrityError as e: + raise ValueError(f"Failed to create user: {e}") from e + + user = self.get(user_id) + if user is None: + raise RuntimeError("Failed to retrieve created user") + return user + + def get(self, user_id: str) -> UserDTO | None: + """Get user by ID.""" + with self._db.transaction() as cursor: + cursor.execute( + """ + SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at + FROM users + WHERE user_id = ? + """, + (user_id,), + ) + row = cursor.fetchone() + + if row is None: + return None + + return UserDTO( + user_id=row[0], + email=row[1], + display_name=row[2], + is_admin=bool(row[3]), + is_active=bool(row[4]), + created_at=datetime.fromisoformat(row[5]), + updated_at=datetime.fromisoformat(row[6]), + last_login_at=datetime.fromisoformat(row[7]) if row[7] else None, + ) + + def get_by_email(self, email: str) -> UserDTO | None: + """Get user by email.""" + with self._db.transaction() as cursor: + cursor.execute( + """ + SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at + FROM users + WHERE email = ? + """, + (email,), + ) + row = cursor.fetchone() + + if row is None: + return None + + return UserDTO( + user_id=row[0], + email=row[1], + display_name=row[2], + is_admin=bool(row[3]), + is_active=bool(row[4]), + created_at=datetime.fromisoformat(row[5]), + updated_at=datetime.fromisoformat(row[6]), + last_login_at=datetime.fromisoformat(row[7]) if row[7] else None, + ) + + def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO: + """Update user.""" + # Check if user exists + user = self.get(user_id) + if user is None: + raise ValueError(f"User {user_id} not found") + + # 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) + + # Build update query dynamically based on provided fields + updates: list[str] = [] + params: list[str | bool | int] = [] + + if changes.display_name is not None: + updates.append("display_name = ?") + params.append(changes.display_name) + + if changes.password is not None: + updates.append("password_hash = ?") + params.append(hash_password(changes.password)) + + if changes.is_admin is not None: + updates.append("is_admin = ?") + params.append(changes.is_admin) + + if changes.is_active is not None: + updates.append("is_active = ?") + params.append(changes.is_active) + + if not updates: + return user + + params.append(user_id) + query = f"UPDATE users SET {', '.join(updates)} WHERE user_id = ?" + + with self._db.transaction() as cursor: + cursor.execute(query, params) + + updated_user = self.get(user_id) + if updated_user is None: + raise RuntimeError("Failed to retrieve updated user") + return updated_user + + def delete(self, user_id: str) -> None: + """Delete user.""" + user = self.get(user_id) + if user is None: + raise ValueError(f"User {user_id} not found") + + with self._db.transaction() as cursor: + cursor.execute("DELETE FROM users WHERE user_id = ?", (user_id,)) + + def authenticate(self, email: str, password: str) -> UserDTO | None: + """Authenticate user credentials.""" + with self._db.transaction() as cursor: + cursor.execute( + """ + SELECT user_id, email, display_name, password_hash, is_admin, is_active, created_at, updated_at, last_login_at + FROM users + WHERE email = ? + """, + (email,), + ) + row = cursor.fetchone() + + if row is None: + return None + + password_hash = row[3] + if not verify_password(password, password_hash): + return None + + # Update last login time + with self._db.transaction() as cursor: + cursor.execute( + "UPDATE users SET last_login_at = ? WHERE user_id = ?", + (datetime.now(timezone.utc).isoformat(), row[0]), + ) + + return UserDTO( + user_id=row[0], + email=row[1], + display_name=row[2], + is_admin=bool(row[4]), + is_active=bool(row[5]), + created_at=datetime.fromisoformat(row[6]), + updated_at=datetime.fromisoformat(row[7]), + last_login_at=datetime.now(timezone.utc), + ) + + def has_admin(self) -> bool: + """Check if any admin user exists.""" + with self._db.transaction() as cursor: + cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = TRUE AND is_active = TRUE") + row = cursor.fetchone() + count = row[0] if row else 0 + return bool(count > 0) + + def create_admin(self, user_data: UserCreateRequest) -> UserDTO: + """Create an admin user (for initial setup).""" + if self.has_admin(): + raise ValueError("Admin user already exists") + + # Force is_admin to True + admin_data = UserCreateRequest( + email=user_data.email, + display_name=user_data.display_name, + password=user_data.password, + is_admin=True, + ) + return self.create(admin_data) + + def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]: + """List all users.""" + with self._db.transaction() as cursor: + cursor.execute( + """ + SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at + FROM users + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, + (limit, offset), + ) + rows = cursor.fetchall() + + return [ + UserDTO( + user_id=row[0], + email=row[1], + display_name=row[2], + is_admin=bool(row[3]), + is_active=bool(row[4]), + created_at=datetime.fromisoformat(row[5]), + updated_at=datetime.fromisoformat(row[6]), + last_login_at=datetime.fromisoformat(row[7]) if row[7] else None, + ) + for row in rows + ] diff --git a/pyproject.toml b/pyproject.toml index adfe5982baf..c83480202a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,14 +65,18 @@ dependencies = [ # Auxiliary dependencies, pinned only if necessary. "blake3", + "bcrypt<4.0.0", "Deprecated", "dnspython", "dynamicprompts", "einops", + "email-validator>=2.0.0", + "passlib[bcrypt]>=1.7.4", "picklescan", "pillow", "prompt-toolkit", "pypatchmatch", + "python-jose[cryptography]>=3.3.0", "python-multipart", "requests", "semver~=3.0.1", diff --git a/tests/app/services/users/test_password_utils.py b/tests/app/services/users/test_password_utils.py new file mode 100644 index 00000000000..68fd37db231 --- /dev/null +++ b/tests/app/services/users/test_password_utils.py @@ -0,0 +1,56 @@ +"""Tests for password utilities.""" + +from invokeai.app.services.auth.password_utils import hash_password, validate_password_strength, verify_password + + +def test_hash_password(): + """Test password hashing.""" + password = "TestPassword123" + hashed = hash_password(password) + + assert hashed != password + assert len(hashed) > 0 + + +def test_verify_password(): + """Test password verification.""" + password = "TestPassword123" + hashed = hash_password(password) + + assert verify_password(password, hashed) + assert not verify_password("WrongPassword", hashed) + + +def test_validate_password_strength_valid(): + """Test password strength validation with valid passwords.""" + valid, msg = validate_password_strength("ValidPass123") + assert valid + assert msg == "" + + +def test_validate_password_strength_too_short(): + """Test password strength validation with short password.""" + valid, msg = validate_password_strength("Pass1") + assert not valid + assert "at least 8 characters" in msg + + +def test_validate_password_strength_no_uppercase(): + """Test password strength validation without uppercase.""" + valid, msg = validate_password_strength("password123") + assert not valid + assert "uppercase" in msg.lower() + + +def test_validate_password_strength_no_lowercase(): + """Test password strength validation without lowercase.""" + valid, msg = validate_password_strength("PASSWORD123") + assert not valid + assert "lowercase" in msg.lower() + + +def test_validate_password_strength_no_digit(): + """Test password strength validation without digit.""" + valid, msg = validate_password_strength("PasswordTest") + assert not valid + assert "number" in msg.lower() diff --git a/tests/app/services/users/test_token_service.py b/tests/app/services/users/test_token_service.py new file mode 100644 index 00000000000..3dec8000829 --- /dev/null +++ b/tests/app/services/users/test_token_service.py @@ -0,0 +1,43 @@ +"""Tests for token service.""" + +from datetime import timedelta + +from invokeai.app.services.auth.token_service import TokenData, create_access_token, verify_token + + +def test_create_access_token(): + """Test creating an access token.""" + data = TokenData(user_id="test-user", email="test@example.com", is_admin=False) + token = create_access_token(data) + + assert token is not None + assert len(token) > 0 + + +def test_verify_valid_token(): + """Test verifying a valid token.""" + data = TokenData(user_id="test-user", email="test@example.com", is_admin=True) + token = create_access_token(data) + + verified_data = verify_token(token) + + assert verified_data is not None + assert verified_data.user_id == data.user_id + assert verified_data.email == data.email + assert verified_data.is_admin == data.is_admin + + +def test_verify_invalid_token(): + """Test verifying an invalid token.""" + verified_data = verify_token("invalid-token") + assert verified_data is None + + +def test_token_with_custom_expiration(): + """Test creating token with custom expiration.""" + data = TokenData(user_id="test-user", email="test@example.com", is_admin=False) + token = create_access_token(data, expires_delta=timedelta(hours=1)) + + verified_data = verify_token(token) + assert verified_data is not None + assert verified_data.user_id == data.user_id diff --git a/tests/app/services/users/test_user_service.py b/tests/app/services/users/test_user_service.py new file mode 100644 index 00000000000..479c911a0da --- /dev/null +++ b/tests/app/services/users/test_user_service.py @@ -0,0 +1,259 @@ +"""Tests for user service.""" + +from logging import Logger + +import pytest + +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.services.users.users_common import UserCreateRequest, UserUpdateRequest +from invokeai.app.services.users.users_default import UserService + + +@pytest.fixture +def logger() -> Logger: + """Create a logger for testing.""" + return Logger("test_user_service") + + +@pytest.fixture +def db(logger: Logger) -> SqliteDatabase: + """Create an in-memory database for testing.""" + db = SqliteDatabase(db_path=None, logger=logger, verbose=False) + # Create users table manually for testing + db._conn.execute(""" + CREATE TABLE users ( + user_id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + password_hash TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + last_login_at DATETIME + ); + """) + db._conn.commit() + return db + + +@pytest.fixture +def user_service(db: SqliteDatabase) -> UserService: + """Create a user service for testing.""" + return UserService(db) + + +def test_create_user(user_service: UserService): + """Test creating a user.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + is_admin=False, + ) + + user = user_service.create(user_data) + + assert user.email == "test@example.com" + assert user.display_name == "Test User" + assert user.is_admin is False + assert user.is_active is True + assert user.user_id is not None + + +def test_create_user_weak_password(user_service: UserService): + """Test creating a user with weak password.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="weak", + is_admin=False, + ) + + with pytest.raises(ValueError, match="at least 8 characters"): + user_service.create(user_data) + + +def test_create_duplicate_user(user_service: UserService): + """Test creating a duplicate user.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + is_admin=False, + ) + + user_service.create(user_data) + + with pytest.raises(ValueError, match="already exists"): + user_service.create(user_data) + + +def test_get_user(user_service: UserService): + """Test getting a user by ID.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + created_user = user_service.create(user_data) + retrieved_user = user_service.get(created_user.user_id) + + assert retrieved_user is not None + assert retrieved_user.user_id == created_user.user_id + assert retrieved_user.email == created_user.email + + +def test_get_nonexistent_user(user_service: UserService): + """Test getting a nonexistent user.""" + user = user_service.get("nonexistent-id") + assert user is None + + +def test_get_user_by_email(user_service: UserService): + """Test getting a user by email.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + created_user = user_service.create(user_data) + retrieved_user = user_service.get_by_email("test@example.com") + + assert retrieved_user is not None + assert retrieved_user.user_id == created_user.user_id + assert retrieved_user.email == "test@example.com" + + +def test_update_user(user_service: UserService): + """Test updating a user.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + user = user_service.create(user_data) + + updates = UserUpdateRequest( + display_name="Updated Name", + is_admin=True, + ) + + updated_user = user_service.update(user.user_id, updates) + + assert updated_user.display_name == "Updated Name" + assert updated_user.is_admin is True + + +def test_delete_user(user_service: UserService): + """Test deleting a user.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + user = user_service.create(user_data) + user_service.delete(user.user_id) + + retrieved_user = user_service.get(user.user_id) + assert retrieved_user is None + + +def test_authenticate_valid_credentials(user_service: UserService): + """Test authenticating with valid credentials.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + user_service.create(user_data) + authenticated_user = user_service.authenticate("test@example.com", "TestPassword123") + + assert authenticated_user is not None + assert authenticated_user.email == "test@example.com" + assert authenticated_user.last_login_at is not None + + +def test_authenticate_invalid_password(user_service: UserService): + """Test authenticating with invalid password.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + user_service.create(user_data) + authenticated_user = user_service.authenticate("test@example.com", "WrongPassword") + + assert authenticated_user is None + + +def test_authenticate_nonexistent_user(user_service: UserService): + """Test authenticating nonexistent user.""" + authenticated_user = user_service.authenticate("nonexistent@example.com", "TestPassword123") + assert authenticated_user is None + + +def test_has_admin(user_service: UserService): + """Test checking if admin exists.""" + assert user_service.has_admin() is False + + user_data = UserCreateRequest( + email="admin@example.com", + display_name="Admin User", + password="AdminPassword123", + is_admin=True, + ) + + user_service.create(user_data) + assert user_service.has_admin() is True + + +def test_create_admin(user_service: UserService): + """Test creating an admin user.""" + user_data = UserCreateRequest( + email="admin@example.com", + display_name="Admin User", + password="AdminPassword123", + ) + + admin = user_service.create_admin(user_data) + + assert admin.is_admin is True + assert admin.email == "admin@example.com" + + +def test_create_admin_when_exists(user_service: UserService): + """Test creating admin when one already exists.""" + user_data = UserCreateRequest( + email="admin@example.com", + display_name="Admin User", + password="AdminPassword123", + ) + + user_service.create_admin(user_data) + + with pytest.raises(ValueError, match="already exists"): + user_service.create_admin(user_data) + + +def test_list_users(user_service: UserService): + """Test listing users.""" + for i in range(5): + user_data = UserCreateRequest( + email=f"test{i}@example.com", + display_name=f"Test User {i}", + password="TestPassword123", + ) + user_service.create(user_data) + + users = user_service.list_users() + assert len(users) == 5 + + limited_users = user_service.list_users(limit=2) + assert len(limited_users) == 2 diff --git a/tests/test_sqlite_migrator.py b/tests/test_sqlite_migrator.py index f6a3cb2a5a9..785844fe469 100644 --- a/tests/test_sqlite_migrator.py +++ b/tests/test_sqlite_migrator.py @@ -296,3 +296,65 @@ def test_idempotent_migrations(migrator: SqliteMigrator, migration_create_test_t # not throwing is sufficient migrator.run_migrations() assert migrator._get_current_version(cursor) == 1 + + +def test_migration_25_creates_users_table(logger: Logger) -> None: + """Test that migration 25 creates the users table and related tables.""" + from invokeai.app.services.shared.sqlite_migrator.migrations.migration_25 import Migration25Callback + + db = SqliteDatabase(db_path=None, logger=logger, verbose=False) + cursor = db._conn.cursor() + + # Create minimal tables that migration 25 expects to exist + cursor.execute("CREATE TABLE IF NOT EXISTS boards (board_id TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS images (image_name TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS workflows (workflow_id TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS session_queue (item_id INTEGER PRIMARY KEY);") + db._conn.commit() + + # Run migration callback directly (not through migrator to avoid chain validation) + migration_callback = Migration25Callback() + migration_callback(cursor) + db._conn.commit() + + # Verify users table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users';") + assert cursor.fetchone() is not None + + # Verify user_sessions table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_sessions';") + assert cursor.fetchone() is not None + + # Verify user_invitations table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_invitations';") + assert cursor.fetchone() is not None + + # Verify shared_boards table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='shared_boards';") + assert cursor.fetchone() is not None + + # Verify system user was created + cursor.execute("SELECT user_id, email FROM users WHERE user_id='system';") + system_user = cursor.fetchone() + assert system_user is not None + assert system_user[0] == "system" + assert system_user[1] == "system@system.invokeai" + + # Verify boards table has user_id column + cursor.execute("PRAGMA table_info(boards);") + columns = [row[1] for row in cursor.fetchall()] + assert "user_id" in columns + assert "is_public" in columns + + # Verify images table has user_id column + cursor.execute("PRAGMA table_info(images);") + columns = [row[1] for row in cursor.fetchall()] + assert "user_id" in columns + + # Verify workflows table has user_id and is_public columns + cursor.execute("PRAGMA table_info(workflows);") + columns = [row[1] for row in cursor.fetchall()] + assert "user_id" in columns + assert "is_public" in columns + + db._conn.close() diff --git a/uv.lock b/uv.lock index f6841cb6e71..a22015f28ff 100644 --- a/uv.lock +++ b/uv.lock @@ -152,6 +152,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, ] +[[package]] +name = "bcrypt" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/36/edc85ab295ceff724506252b774155eff8a238f13730c8b13badd33ef866/bcrypt-3.2.2.tar.gz", hash = "sha256:433c410c2177057705da2a9f2cd01dd157493b2a7ac14c8593a16b3dab6b6bfb", size = 42455, upload-time = "2022-05-01T17:58:52.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c2/05354b1d4351d2e686a32296cc9dd1e63f9909a580636df0f7b06d774600/bcrypt-3.2.2-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:7180d98a96f00b1050e93f5b0f556e658605dd9f524d0b0e68ae7944673f525e", size = 50049, upload-time = "2022-05-01T18:05:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b3/1257f7d64ee0aa0eb4fb1de5da8c2647a57db7b737da1f2342ac1889d3b8/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:61bae49580dce88095d669226d5076d0b9d927754cedbdf76c6c9f5099ad6f26", size = 54914, upload-time = "2022-05-01T18:03:00.752Z" }, + { url = "https://files.pythonhosted.org/packages/61/3d/dce83194830183aa700cab07c89822471d21663a86a0b305d1e5c7b02810/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88273d806ab3a50d06bc6a2fc7c87d737dd669b76ad955f449c43095389bc8fb", size = 54403, upload-time = "2022-05-01T18:03:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/86/1b/f4d7425dfc6cd0e405b48ee484df6d80fb39e05f25963dbfcc2c511e8341/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6d2cb9d969bfca5bc08e45864137276e4c3d3d7de2b162171def3d188bf9d34a", size = 62337, upload-time = "2022-05-01T18:05:49.524Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/289db4f31b303de6addb0897c8b5c01b23bd4b8c511ac80a32b08658847c/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b02d6bfc6336d1094276f3f588aa1225a598e27f8e3388f4db9948cb707b521", size = 61026, upload-time = "2022-05-01T18:05:51.107Z" }, + { url = "https://files.pythonhosted.org/packages/40/8f/b67b42faa2e4d944b145b1a402fc08db0af8fe2dfa92418c674b5a302496/bcrypt-3.2.2-cp36-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a2c46100e315c3a5b90fdc53e429c006c5f962529bc27e1dfd656292c20ccc40", size = 64672, upload-time = "2022-05-01T18:05:52.748Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9a/e1867f0b27a3f4ce90e21dd7f322f0e15d4aac2434d3b938dcf765e47c6b/bcrypt-3.2.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7d9ba2e41e330d2af4af6b1b6ec9e6128e91343d0b4afb9282e54e5508f31baa", size = 56795, upload-time = "2022-05-01T18:03:04.028Z" }, + { url = "https://files.pythonhosted.org/packages/18/76/057b0637c880e6cb0abdc8a867d080376ddca6ed7d05b7738f589cc5c1a8/bcrypt-3.2.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cd43303d6b8a165c29ec6756afd169faba9396a9472cdff753fe9f19b96ce2fa", size = 62075, upload-time = "2022-05-01T18:05:54.412Z" }, + { url = "https://files.pythonhosted.org/packages/f1/64/cd93e2c3e28a5fa8bcf6753d5cc5e858e4da08bf51404a0adb6a412532de/bcrypt-3.2.2-cp36-abi3-win32.whl", hash = "sha256:4e029cef560967fb0cf4a802bcf4d562d3d6b4b1bf81de5ec1abbe0f1adb027e", size = 27916, upload-time = "2022-05-01T18:05:56.45Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/7cd297ff571c4d86371ff024c0e008b37b59e895b28f69444a9b6f94ca1a/bcrypt-3.2.2-cp36-abi3-win_amd64.whl", hash = "sha256:7ff2069240c6bbe49109fe84ca80508773a904f5a8cb960e02a977f7f519b129", size = 29581, upload-time = "2022-05-01T18:05:57.878Z" }, +] + [[package]] name = "bidict" version = "0.23.1" @@ -499,7 +520,7 @@ name = "cryptography" version = "45.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra == 'extra-8-invokeai-cpu') or (platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra == 'extra-8-invokeai-cuda') or (platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra != 'extra-8-invokeai-rocm') or (platform_python_implementation == 'PyPy' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_python_implementation == 'PyPy' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_python_implementation == 'PyPy' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy' or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } wheels = [ @@ -624,6 +645,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/f0/dbe05efee6a38fb075ba0995e497223d02c6d056303d5e8881e9bb20652a/dynamicprompts-0.31.0-py3-none-any.whl", hash = "sha256:a07f38c295ec2b77905cecba8b0f439bb1a84942bfb6874ff6b55448e2cc950e", size = 53524, upload-time = "2024-03-21T07:58:36.994Z" }, ] +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + [[package]] name = "einops" version = "0.8.1" @@ -633,6 +666,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/62/9773de14fe6c45c23649e98b83231fffd7b9892b6cf863251dc2afa73643/einops-0.8.1-py3-none-any.whl", hash = "sha256:919387eb55330f5757c6bea9165c5ff5cfe63a642682ea788a6d472576d81737", size = 64359, upload-time = "2025-02-09T03:17:01.998Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "faker" version = "37.4.0" @@ -961,6 +1007,7 @@ name = "invokeai" source = { editable = "." } dependencies = [ { name = "accelerate" }, + { name = "bcrypt" }, { name = "bitsandbytes", marker = "sys_platform != 'darwin' or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "blake3" }, { name = "compel" }, @@ -969,6 +1016,7 @@ dependencies = [ { name = "dnspython" }, { name = "dynamicprompts" }, { name = "einops" }, + { name = "email-validator" }, { name = "fastapi" }, { name = "fastapi-events" }, { name = "gguf" }, @@ -978,12 +1026,14 @@ dependencies = [ { name = "onnx" }, { name = "onnxruntime" }, { name = "opencv-contrib-python" }, + { name = "passlib", extra = ["bcrypt"] }, { name = "picklescan" }, { name = "pillow" }, { name = "prompt-toolkit" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pypatchmatch" }, + { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, { name = "python-socketio" }, { name = "pywavelets" }, @@ -1067,6 +1117,7 @@ xformers = [ [package.metadata] requires-dist = [ { name = "accelerate" }, + { name = "bcrypt", specifier = "<4.0.0" }, { name = "bitsandbytes", marker = "sys_platform != 'darwin'" }, { name = "blake3" }, { name = "compel", specifier = "==2.1.1" }, @@ -1075,6 +1126,7 @@ requires-dist = [ { name = "dnspython" }, { name = "dynamicprompts" }, { name = "einops" }, + { name = "email-validator", specifier = ">=2.0.0" }, { name = "fastapi", specifier = "==0.118.3" }, { name = "fastapi-events" }, { name = "gguf" }, @@ -1096,6 +1148,7 @@ requires-dist = [ { name = "onnxruntime-directml", marker = "extra == 'onnx-directml'" }, { name = "onnxruntime-gpu", marker = "extra == 'onnx-cuda'" }, { name = "opencv-contrib-python" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "picklescan" }, { name = "pillow" }, { name = "pip-tools", marker = "extra == 'dist'" }, @@ -1111,6 +1164,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pytest-datadir", marker = "extra == 'test'" }, { name = "pytest-timeout", marker = "extra == 'test'" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, { name = "python-multipart" }, { name = "python-socketio" }, { name = "pytorch-triton-rocm", marker = "sys_platform == 'linux' and extra == 'rocm'", index = "https://download.pytorch.org/whl/rocm6.3", conflict = { package = "invokeai", extra = "rocm" } }, @@ -2300,6 +2354,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -2498,6 +2566,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/01/069766294390d3e10c77dfb553171466d67ffb51bf72a437650c0a5db86a/pudb-2025.1-py3-none-any.whl", hash = "sha256:f642d42e6054c992b43c463742650aa879fe290d7d7ffdeb21f7d00dc4587a21", size = 89208, upload-time = "2025-05-06T20:43:17.101Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -2747,6 +2824,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/fa/df59acedf7bbb937f69174d00f921a7b93aa5a5f5c17d05296c814fff6fc/python_engineio-4.12.2-py3-none-any.whl", hash = "sha256:8218ab66950e179dfec4b4bbb30aecf3f5d86f5e58e6fc1aa7fde2c698b2804f", size = 59536, upload-time = "2025-06-04T19:22:16.916Z" }, ] +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + [[package]] name = "python-multipart" version = "0.0.20" @@ -3001,6 +3097,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.11.13"