CoolFace
Apppublic

Blablablab/audio-classification

sourceHugging Faceapache-2.0updated 1mo agoView on Hugging Face
0likes
authentication.py752 linesDownload Raw Back to potato
1"""2Authentication System Module3 4This module provides a comprehensive authentication system for the Potato annotation platform.5It supports multiple authentication backends including in-memory storage, database storage,6and third-party SSO providers like Clerk.7 8The system is designed to be extensible and supports both password-based and passwordless9authentication modes. It includes user management, session validation, and secure10password handling.11 12Key Features:13- Multiple authentication backends (in-memory, database, Clerk SSO)14- Password hashing with PBKDF2 and per-user salts15- Passwordless authentication support16- User registration and management17- Password reset with secure tokens18- Session-based authentication19- Configurable authentication requirements20"""21 22import os23import json24import logging25import hashlib26import hmac27import secrets28import sqlite329import requests30import threading31import time32from abc import ABC, abstractmethod33from typing import Optional, Dict, Any, List, Union34 35logger = logging.getLogger(__name__)36 37# Global singleton instance of the user authenticator with thread-safe lock38USER_AUTHENTICATOR_SINGLETON = None39_USER_AUTHENTICATOR_LOCK = threading.Lock()40 41# Format for per-user salt storage: "<32-char-hex-salt>$<hash-hex>"42_SALT_HASH_SEPARATOR = "$"43 44 45def _is_salted_hash(value: str) -> bool:46    """Check if a stored password value is in the per-user salt$hash format."""47    if not value or _SALT_HASH_SEPARATOR not in value:48        return False49    parts = value.split(_SALT_HASH_SEPARATOR, 1)50    # salt is 32 hex chars (16 bytes), hash is 64 hex chars (32 bytes sha256)51    return len(parts) == 2 and len(parts[0]) == 32 and len(parts[1]) == 6452 53 54def _hash_password_with_salt(password: str, salt: str = None) -> str:55    """Hash a password with a per-user salt using PBKDF2.56 57    Args:58        password: The plain text password to hash59        salt: Hex-encoded salt string. If None, generates a new random salt.60 61    Returns:62        str: The combined "salt$hash" string63    """64    if not password:65        return ""66    if salt is None:67        salt = secrets.token_hex(16)68    hash_value = hashlib.pbkdf2_hmac(69        'sha256',70        password.encode('utf-8'),71        salt.encode('utf-8'),72        10000073    ).hex()74    return f"{salt}{_SALT_HASH_SEPARATOR}{hash_value}"75 76 77def _verify_password(password: str, stored: str) -> bool:78    """Verify a password against a stored salt$hash value using constant-time comparison."""79    if not password or not stored:80        return False81    if not _is_salted_hash(stored):82        return False83    salt, expected_hash = stored.split(_SALT_HASH_SEPARATOR, 1)84    actual_hash = hashlib.pbkdf2_hmac(85        'sha256',86        password.encode('utf-8'),87        salt.encode('utf-8'),88        10000089    ).hex()90    return hmac.compare_digest(expected_hash, actual_hash)91 92 93class AuthBackend(ABC):94    """95    Abstract base class for authentication backends.96 97    This class defines the interface that all authentication backends must implement.98    It provides a consistent API for user authentication, registration, and validation99    regardless of the underlying storage mechanism.100    """101    @abstractmethod102    def authenticate(self, username: str, password: Optional[str]) -> bool:103        """Authenticate a user against this backend."""104        pass105 106    @abstractmethod107    def add_user(self, username: str, password: Optional[str], **kwargs) -> str:108        """Add a user to this backend. Returns status message."""109        pass110 111    @abstractmethod112    def is_valid_username(self, username: str) -> bool:113        """Check if a username exists in this backend."""114        pass115 116    @abstractmethod117    def update_password(self, username: str, new_password: str) -> bool:118        """Update a user's password. Returns True on success."""119        pass120 121    @abstractmethod122    def get_all_users(self) -> List[str]:123        """Return list of all usernames."""124        pass125 126    def add_user_prehashed(self, username: str, hashed_password: str, **kwargs) -> str:127        """Load user with already-hashed password (for file loading). Override in subclasses."""128        raise NotImplementedError("This backend does not support loading pre-hashed passwords")129 130 131class InMemoryAuthBackend(AuthBackend):132    """133    Authentication backend that stores users in memory with per-user salts.134 135    Password storage format: "salt$hash" where salt is 32 hex chars and hash is 64 hex chars.136    """137    def __init__(self):138        self.users = {}  # username -> "salt$hash"139        self.user_data = {}  # username -> additional data140 141    def authenticate(self, username: str, password: Optional[str]) -> bool:142        if username not in self.users:143            return False144        if password is None:  # Passwordless login145            return True146        return _verify_password(password, self.users[username])147 148    def add_user(self, username: str, password: Optional[str], **kwargs) -> str:149        if username in self.users:150            return "Duplicate user"151        self.users[username] = _hash_password_with_salt(password) if password else ""152        self.user_data[username] = kwargs153        return "Success"154 155    def add_user_prehashed(self, username: str, hashed_password: str, **kwargs) -> str:156        """Store a user with an already-hashed password (salt$hash format)."""157        if username in self.users:158            return "Duplicate user"159        self.users[username] = hashed_password160        self.user_data[username] = kwargs161        return "Success"162 163    def is_valid_username(self, username: str) -> bool:164        return username in self.users165 166    def update_password(self, username: str, new_password: str) -> bool:167        if username not in self.users:168            return False169        self.users[username] = _hash_password_with_salt(new_password)170        return True171 172    def get_all_users(self) -> List[str]:173        return list(self.users.keys())174 175 176class DatabaseAuthBackend(AuthBackend):177    """178    Authentication backend using SQLite (stdlib) or PostgreSQL (psycopg2).179 180    Connection string formats:181        sqlite:///path/to/db.db     (relative or absolute)182        postgresql://user:pass@host/dbname183    """184    def __init__(self, db_connection_string: str):185        self.db_connection_string = db_connection_string186        self._lock = threading.Lock()187        self._db_type = None  # 'sqlite' or 'postgresql'188        self._connection = None189 190        if db_connection_string.startswith("sqlite:///"):191            self._db_type = "sqlite"192            self._init_sqlite(db_connection_string[len("sqlite:///"):])193        elif db_connection_string.startswith("postgresql://"):194            self._db_type = "postgresql"195            self._init_postgresql(db_connection_string)196        else:197            raise ValueError(198                f"Unsupported database URL: {db_connection_string}. "199                "Use sqlite:///path/to/db or postgresql://user:pass@host/dbname"200            )201 202        logger.info(f"Database auth backend initialized ({self._db_type})")203 204    def _init_sqlite(self, db_path: str):205        """Initialize SQLite database."""206        # Create parent directories if needed207        db_dir = os.path.dirname(db_path)208        if db_dir:209            os.makedirs(db_dir, exist_ok=True)210 211        self._connection = sqlite3.connect(db_path, check_same_thread=False)212        self._connection.execute("PRAGMA journal_mode=WAL")213        self._connection.execute("""214            CREATE TABLE IF NOT EXISTS users (215                username TEXT PRIMARY KEY,216                password_hash TEXT NOT NULL,217                email TEXT,218                created_at TEXT DEFAULT (datetime('now')),219                updated_at TEXT DEFAULT (datetime('now'))220            )221        """)222        self._connection.commit()223 224    def _init_postgresql(self, connection_string: str):225        """Initialize PostgreSQL database."""226        try:227            import psycopg2228        except ImportError:229            raise ImportError(230                "psycopg2 is required for PostgreSQL authentication backend. "231                "Install it with: pip install psycopg2-binary"232            )233        self._connection = psycopg2.connect(connection_string)234        self._connection.autocommit = True235        with self._connection.cursor() as cur:236            cur.execute("""237                CREATE TABLE IF NOT EXISTS users (238                    username TEXT PRIMARY KEY,239                    password_hash TEXT NOT NULL,240                    email TEXT,241                    created_at TIMESTAMP DEFAULT NOW(),242                    updated_at TIMESTAMP DEFAULT NOW()243                )244            """)245 246    def _execute(self, query: str, params: tuple = (), fetch: str = None):247        """Thread-safe query execution.248 249        Args:250            query: SQL query with ? placeholders (auto-converted to %s for PostgreSQL)251            params: Query parameters252            fetch: None, 'one', or 'all'253 254        Returns:255            Query result based on fetch parameter256        """257        with self._lock:258            if self._db_type == "postgresql":259                query = query.replace("?", "%s")260 261            if self._db_type == "sqlite":262                cursor = self._connection.cursor()263                cursor.execute(query, params)264                if fetch == "one":265                    result = cursor.fetchone()266                elif fetch == "all":267                    result = cursor.fetchall()268                else:269                    self._connection.commit()270                    result = None271                cursor.close()272                return result273            else:274                with self._connection.cursor() as cur:275                    cur.execute(query, params)276                    if fetch == "one":277                        return cur.fetchone()278                    elif fetch == "all":279                        return cur.fetchall()280                    return None281 282    def authenticate(self, username: str, password: Optional[str]) -> bool:283        row = self._execute(284            "SELECT password_hash FROM users WHERE username = ?",285            (username,), fetch="one"286        )287        if not row:288            return False289        if password is None:  # Passwordless login290            return True291        return _verify_password(password, row[0])292 293    def add_user(self, username: str, password: Optional[str], **kwargs) -> str:294        existing = self._execute(295            "SELECT 1 FROM users WHERE username = ?",296            (username,), fetch="one"297        )298        if existing:299            return "Duplicate user"300 301        hashed = _hash_password_with_salt(password) if password else ""302        email = kwargs.get("email", "")303        self._execute(304            "INSERT INTO users (username, password_hash, email) VALUES (?, ?, ?)",305            (username, hashed, email)306        )307        return "Success"308 309    def add_user_prehashed(self, username: str, hashed_password: str, **kwargs) -> str:310        """Store a user with an already-hashed password."""311        existing = self._execute(312            "SELECT 1 FROM users WHERE username = ?",313            (username,), fetch="one"314        )315        if existing:316            return "Duplicate user"317 318        email = kwargs.get("email", "")319        self._execute(320            "INSERT INTO users (username, password_hash, email) VALUES (?, ?, ?)",321            (username, hashed_password, email)322        )323        return "Success"324 325    def is_valid_username(self, username: str) -> bool:326        row = self._execute(327            "SELECT 1 FROM users WHERE username = ?",328            (username,), fetch="one"329        )330        return row is not None331 332    def update_password(self, username: str, new_password: str) -> bool:333        if not self.is_valid_username(username):334            return False335        hashed = _hash_password_with_salt(new_password)336        if self._db_type == "sqlite":337            self._execute(338                "UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE username = ?",339                (hashed, username)340            )341        else:342            self._execute(343                "UPDATE users SET password_hash = ?, updated_at = NOW() WHERE username = ?",344                (hashed, username)345            )346        return True347 348    def get_all_users(self) -> List[str]:349        rows = self._execute("SELECT username FROM users", fetch="all")350        return [r[0] for r in rows]351 352    def close(self):353        """Close the database connection."""354        if self._connection:355            self._connection.close()356            self._connection = None357 358 359class ClerkAuthBackend(AuthBackend):360    """361    Authentication backend that uses Clerk for SSO.362    """363    def __init__(self, api_key: str, frontend_api: str):364        self.api_key = api_key365        self.frontend_api = frontend_api366        self.users = {}  # Cache of known users367        logger.info("Clerk SSO backend initialized")368 369    def authenticate(self, username: str, token: Optional[str]) -> bool:370        if not token:371            return False372        try:373            headers = {374                "Authorization": f"Bearer {self.api_key}",375                "Content-Type": "application/json"376            }377            response = requests.get(378                f"https://api.clerk.dev/v1/sessions/{token}",379                headers=headers380            )381            if response.status_code == 200:382                user_data = response.json()383                self.users[username] = user_data384                return True385            return False386        except Exception as e:387            logger.error(f"Error authenticating with Clerk: {str(e)}")388            return False389 390    def add_user(self, username: str, password: Optional[str], **kwargs) -> str:391        return "User management happens through Clerk dashboard"392 393    def is_valid_username(self, username: str) -> bool:394        return username in self.users395 396    def update_password(self, username: str, new_password: str) -> bool:397        raise NotImplementedError("Password management is handled by Clerk")398 399    def get_all_users(self) -> List[str]:400        return list(self.users.keys())401 402 403class UserAuthenticator:404    """405    A class for maintaining state on which users are allowed to use the system.406 407    This class provides a unified interface for user authentication and management408    regardless of the underlying backend. It supports multiple authentication methods409    and can be configured for passwordless operation.410    """411 412    def __init__(self, user_config_path, auth_method="in_memory", auth_config=None):413        self.allow_all_users = True414        self.user_config_path = user_config_path415        self.user_config_path_explicit = False  # Set to True if path was explicitly configured416        self.authorized_users = []417        self.userlist = []418        self.usernames = set()419        self.users = {}420        self.required_user_info_keys = ["username", "password"]421        self.require_password = True422        self.auth_method = auth_method423        self.auth_config = auth_config or {}424        self.auth_backend = self._initialize_backend(auth_method, auth_config)425 426        # Token management for password reset427        self._reset_tokens = {}  # token -> {username, expires}428        self._token_lock = threading.Lock()429 430        # Track load outcomes so init_from_config can warn on a silently empty431        # (e.g. wrong-format) user file. F-036.432        self.users_loaded_from_file = 0433        self.user_file_parse_errors = 0434 435        # Load users from config file if it exists436        if os.path.isfile(self.user_config_path):437            logger.info(f"Loading users from {self.user_config_path}")438            before = len(self.users)439            with open(self.user_config_path, "rt", encoding="utf-8") as f:440                for lineno, line in enumerate(f.readlines(), start=1):441                    line = line.strip()442                    if not line:443                        continue444                    # Tolerate a malformed line instead of aborting the whole445                    # load (and crashing server boot) on one bad row.446                    try:447                        single_user = json.loads(line)448                    except (ValueError, TypeError) as e:449                        self.user_file_parse_errors += 1450                        logger.error(451                            f"User file {self.user_config_path} line {lineno}: "452                            f"not valid JSON ({e}); skipping. Expected JSONL — "453                            f'one object per line, e.g. {{"username": "alice", "password": "x"}}'454                        )455                        continue456                    # Detect salt$hash format in password field457                    password_val = single_user.get("password", "") if isinstance(single_user, dict) else ""458                    if password_val and _is_salted_hash(password_val):459                        self._add_user_prehashed(single_user)460                    else:461                        self.add_single_user(single_user)462            self.users_loaded_from_file = len(self.users) - before463 464    def _initialize_backend(self, auth_method: str, auth_config: dict = None) -> AuthBackend:465        if auth_method == "in_memory":466            return InMemoryAuthBackend()467        elif auth_method == "database":468            db_url = (auth_config or {}).get("database_url") or \469                     os.environ.get("POTATO_DB_CONNECTION", "sqlite:///potato_users.db")470            return DatabaseAuthBackend(db_url)471        elif auth_method == "clerk":472            api_key = os.environ.get("CLERK_API_KEY", "")473            frontend_api = os.environ.get("CLERK_FRONTEND_API", "")474            if not api_key:475                logger.error("CLERK_API_KEY environment variable is not set")476                raise ValueError("CLERK_API_KEY must be set for Clerk authentication")477            return ClerkAuthBackend(api_key, frontend_api)478        elif auth_method == "oauth":479            from potato.auth_backends.oauth_backend import OAuthBackend480            if not auth_config:481                raise ValueError("OAuth authentication requires an 'authentication' config section with 'providers'")482            return OAuthBackend(auth_config)483        else:484            logger.error(f"Unknown authentication method: {auth_method}")485            raise ValueError(f"Unknown authentication method: {auth_method}")486 487    @staticmethod488    def init_from_config(config: dict) -> "UserAuthenticator":489        """Initialize the UserAuthenticator from a configuration dictionary (singleton)."""490        global USER_AUTHENTICATOR_SINGLETON491 492        if USER_AUTHENTICATOR_SINGLETON is None:493            with _USER_AUTHENTICATOR_LOCK:494                if USER_AUTHENTICATOR_SINGLETON is None:495                    auth_method = config.get("authentication", {}).get("method", "in_memory")496                    user_config_path = config.get("authentication", {}).get("user_config_path", None)497                    require_password = config.get("require_password", True)498 499                    path_explicit = user_config_path is not None500 501                    if user_config_path is None:502                        config_dir = os.path.dirname(config['output_annotation_dir'])503                        user_config_path = os.path.join(config_dir, "user_config.json")504                    else:505                        # Don't raise if file doesn't exist — it will be created on first registration506                        if not os.path.isfile(user_config_path):507                            logger.info(f"user_config_path '{user_config_path}' does not exist yet; will be created on first registration")508 509                    logger.debug(f"User config path: {user_config_path}")510 511                    auth_config = config.get("authentication", {})512 513                    USER_AUTHENTICATOR_SINGLETON = UserAuthenticator(user_config_path, auth_method, auth_config)514                    USER_AUTHENTICATOR_SINGLETON.require_password = require_password515                    USER_AUTHENTICATOR_SINGLETON.user_config_path_explicit = path_explicit516 517                    # F-036: a user file was explicitly configured and exists, but518                    # produced zero usable users (e.g. wrong format / all rows519                    # invalid). With closed enrolment this is a silently broken520                    # deployment — nobody can log in. Warn prominently.521                    _auth = USER_AUTHENTICATOR_SINGLETON522                    if (path_explicit and os.path.isfile(user_config_path)523                            and _auth.users_loaded_from_file == 0):524                        allow_all = config.get("user_config", {}).get("allow_all_users", False)525                        logger.warning(526                            "user_config_path '%s' was configured but loaded 0 users "527                            "(%d malformed line(s)). Expected JSONL — one object per "528                            'line, e.g. {"username": "alice", "password": "x"}. %s',529                            user_config_path, _auth.user_file_parse_errors,530                            ("Open registration is on, so new users can still self-register."531                             if allow_all else532                             "allow_all_users is false, so NO ONE will be able to log in."),533                        )534 535                    logger.info(f"Initialized UserAuthenticator with method: {auth_method}, require_password: {require_password}")536 537        return USER_AUTHENTICATOR_SINGLETON538 539    @staticmethod540    def get_instance():541        global USER_AUTHENTICATOR_SINGLETON542        if USER_AUTHENTICATOR_SINGLETON is None:543            raise ValueError("UserAuthenticator not initialized; call init_from_config first")544        return USER_AUTHENTICATOR_SINGLETON545 546    @staticmethod547    def authenticate(username: str, password: Optional[str]) -> bool:548        authenticator = UserAuthenticator.get_instance()549 550        if not authenticator.auth_backend.is_valid_username(username):551            logger.warning(f"Authentication failed: user '{username}' does not exist")552            return False553 554        if not authenticator.require_password:555            logger.debug(f"Passwordless authentication for user: {username}")556            return authenticator.auth_backend.authenticate(username, None)557 558        return authenticator.auth_backend.authenticate(username, password)559 560    def add_user(self, username, password: Optional[str], **kwargs):561        """Add a user to the authentication system."""562        if not self.require_password:563            logger.debug(f"Passwordless mode - allowing any user: {username}")564        elif self.allow_all_users == False and not self.is_authorized_user(username):565            return "Unauthorized user"566 567        result = self.auth_backend.add_user(username, password, **kwargs)568        if result == "Success":569            user_data = {"username": username}570            user_data.update(kwargs)571            self.users[username] = user_data572            self.userlist.append(username)573        return result574 575    def _add_user_prehashed(self, single_user):576        """Add a user with an already-hashed password (loaded from file)."""577        username = single_user["username"]578        hashed_password = single_user.get("password", "")579 580        result = self.auth_backend.add_user_prehashed(581            username,582            hashed_password,583            **{k: v for k, v in single_user.items() if k not in ["username", "password"]}584        )585 586        if result == "Success":587            self.users[username] = single_user588            self.userlist.append(username)589 590        return result591 592    def add_single_user(self, single_user):593        """Add a single user to the full user dict."""594        if not self.require_password:595            logger.debug(f"Passwordless mode - allowing any user: {single_user['username']}")596        elif self.allow_all_users == False and not self.is_authorized_user(single_user["username"]):597            return "Unauthorized user"598 599        if not self.require_password:600            required_keys = ["username"]601        else:602            required_keys = self.required_user_info_keys603 604        for key in required_keys:605            if key not in single_user:606                logger.error(f"Missing {key} in user info")607                return f"Missing {key} in user info"608 609        result = self.auth_backend.add_user(610            single_user["username"],611            single_user.get("password"),612            **{k: v for k, v in single_user.items() if k not in ["username", "password"]}613        )614 615        if result == "Success":616            self.users[single_user["username"]] = single_user617            self.userlist.append(single_user["username"])618 619        return result620 621    def update_password(self, username: str, new_password: str) -> bool:622        """Update a user's password via the backend."""623        result = self.auth_backend.update_password(username, new_password)624        if result and username in self.users:625            # Update the stored user dict with the new hash for save_user_config626            if isinstance(self.users[username], dict):627                self.users[username]["password"] = self.auth_backend.users[username] \628                    if hasattr(self.auth_backend, 'users') else _hash_password_with_salt(new_password)629        return result630 631    def save_user_config(self):632        """Save user config to file.633 634        Saves when:635        - auth_method is in_memory AND user_config_path was explicitly configured636        - auth_method is not in_memory and not database (other file-based methods)637 638        Skips when:639        - auth_method is database (DB handles its own persistence)640        - auth_method is in_memory with auto-generated default path (preserve old behavior)641        """642        if self.auth_method == "database":643            logger.debug("User config not saved - using database authentication (DB handles persistence)")644            return645 646        if self.auth_method == "in_memory" and not self.user_config_path_explicit:647            logger.debug("User config not saved - using in_memory with default path")648            return649 650        if self.user_config_path:651            with open(self.user_config_path, "wt", encoding="utf-8") as f:652                for k in self.userlist:653                    user_data = self.users.get(k, {})654                    if isinstance(user_data, dict):655                        # Ensure password field contains the hashed value656                        output = dict(user_data)657                        if hasattr(self.auth_backend, 'users') and k in self.auth_backend.users:658                            output["password"] = self.auth_backend.users[k]659                        f.write(json.dumps(output) + "\n")660                    else:661                        f.write(json.dumps({"username": k}) + "\n")662            logger.info(f"User info file saved at: {self.user_config_path}")663        else:664            logger.warning("WARNING: user_config_path not specified, user registration info are not saved")665 666    # --- Token-based password reset ---667 668    def create_reset_token(self, username: str, ttl_hours: int = 24) -> Optional[str]:669        """Create a password reset token for a user.670 671        Args:672            username: The username to create a token for673            ttl_hours: Token validity in hours (default 24)674 675        Returns:676            The token string, or None if user doesn't exist677        """678        if not self.auth_backend.is_valid_username(username):679            return None680 681        token = secrets.token_urlsafe(32)682        expires = time.time() + (ttl_hours * 3600)683 684        with self._token_lock:685            # Invalidate any existing tokens for this user686            self._reset_tokens = {687                t: v for t, v in self._reset_tokens.items()688                if v["username"] != username689            }690            self._reset_tokens[token] = {691                "username": username,692                "expires": expires693            }694 695        return token696 697    def validate_reset_token(self, token: str) -> Optional[str]:698        """Validate a reset token and return the username, or None if invalid/expired."""699        with self._token_lock:700            # Clean expired tokens701            now = time.time()702            self._reset_tokens = {703                t: v for t, v in self._reset_tokens.items()704                if v["expires"] > now705            }706 707            if token not in self._reset_tokens:708                return None709            return self._reset_tokens[token]["username"]710 711    def consume_reset_token(self, token: str) -> Optional[str]:712        """Validate, delete, and return the username for a reset token. Single-use."""713        with self._token_lock:714            now = time.time()715            self._reset_tokens = {716                t: v for t, v in self._reset_tokens.items()717                if v["expires"] > now718            }719 720            if token not in self._reset_tokens:721                return None722            username = self._reset_tokens[token]["username"]723            del self._reset_tokens[token]724            return username725 726    # --- End token management ---727 728    def is_authorized_user(self, username):729        return username in self.authorized_users730 731    def is_valid_username(self, username):732        return self.auth_backend.is_valid_username(username)733 734    def is_valid_password(self, username, password):735        return self.authenticate(username, password)736 737    def get_clerk_frontend_api(self) -> str:738        if self.auth_method == "clerk" and isinstance(self.auth_backend, ClerkAuthBackend):739            return self.auth_backend.frontend_api740        return ""741 742    def get_oauth_backend(self):743        if self.auth_method == "oauth":744            return self.auth_backend745        return None746 747    def get_login_providers(self) -> list:748        oauth_backend = self.get_oauth_backend()749        if oauth_backend:750            return oauth_backend.get_login_providers()751        return []752