mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-06 02:48:04 +00:00
feat(auth): add argon2 password hashing support to remove 72-byte limit
bcrypt silently truncates passwords longer than 72 bytes, which is a known security footgun. This change introduces opt-in argon2 support via PASSWORD_HASH_ALGORITHM=argon2, which has no such limit. - Add PASSWORD_HASH_ALGORITHM env var (default: 'bcrypt' for backward compat) - get_password_hash() uses argon2 or bcrypt based on the setting - verify_password() auto-detects algorithm from hash prefix ($argon2 vs $2b$), so existing bcrypt hashes continue to work after switching algorithms - validate_password() only enforces the 72-byte limit when bcrypt is active - Remove the silent truncation hack in auths.py signin handler - Update PASSWORD_TOO_LONG error message to mention the escape hatch argon2-cffi was already a declared dependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1a97751e37
commit
9dbe0c4275
4 changed files with 28 additions and 24 deletions
|
|
@ -26,7 +26,8 @@ class ERROR_MESSAGES(str, Enum):
|
|||
EMAIL_TAKEN = 'Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew.'
|
||||
USERNAME_TAKEN = 'Uh-oh! This username is already registered. Please choose another username.'
|
||||
PASSWORD_TOO_LONG = (
|
||||
'Uh-oh! The password you entered is too long. Please make sure your password is less than 72 bytes long.'
|
||||
'Uh-oh! The password you entered is too long. When using bcrypt, passwords must be 72 bytes or fewer. '
|
||||
'Set PASSWORD_HASH_ALGORITHM=argon2 to remove this limit.'
|
||||
)
|
||||
COMMAND_TAKEN = 'Uh-oh! This command is already registered. Please choose another command string.'
|
||||
FILE_EXISTS = 'Uh-oh! This file is already registered. Please choose another file.'
|
||||
|
|
|
|||
|
|
@ -662,6 +662,7 @@ WEBUI_AUTH_TRUSTED_ROLE_HEADER = os.getenv('WEBUI_AUTH_TRUSTED_ROLE_HEADER', Non
|
|||
CUSTOM_API_KEY_HEADER = os.getenv('CUSTOM_API_KEY_HEADER', 'x-api-key')
|
||||
|
||||
ENABLE_PASSWORD_VALIDATION = os.getenv('ENABLE_PASSWORD_VALIDATION', 'False').lower() == 'true'
|
||||
PASSWORD_HASH_ALGORITHM = os.getenv('PASSWORD_HASH_ALGORITHM', 'bcrypt').lower()
|
||||
PASSWORD_VALIDATION_REGEX_PATTERN = os.getenv(
|
||||
'PASSWORD_VALIDATION_REGEX_PATTERN',
|
||||
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$',
|
||||
|
|
|
|||
|
|
@ -651,15 +651,6 @@ async def signin(
|
|||
detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED,
|
||||
)
|
||||
|
||||
password_bytes = form_data.password.encode('utf-8')
|
||||
if len(password_bytes) > 72:
|
||||
# TODO: Implement other hashing algorithms that support longer passwords
|
||||
log.info('Password too long, truncating to 72 bytes for bcrypt')
|
||||
password_bytes = password_bytes[:72]
|
||||
|
||||
# decode safely — ignore incomplete UTF-8 sequences
|
||||
form_data.password = password_bytes.decode('utf-8', errors='ignore')
|
||||
|
||||
user = await Auths.authenticate_user(
|
||||
form_data.email.lower(),
|
||||
lambda pw: verify_password(form_data.password, pw),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ from datetime import datetime, timedelta
|
|||
from typing import Optional, Union
|
||||
|
||||
import bcrypt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||
import jwt
|
||||
import pytz
|
||||
import requests
|
||||
|
|
@ -25,6 +27,7 @@ from open_webui.env import (
|
|||
ENABLE_PASSWORD_VALIDATION,
|
||||
LICENSE_BLOB,
|
||||
OFFLINE_MODE,
|
||||
PASSWORD_HASH_ALGORITHM,
|
||||
PASSWORD_VALIDATION_HINT,
|
||||
PASSWORD_VALIDATION_REGEX_PATTERN,
|
||||
REDIS_KEY_PREFIX,
|
||||
|
|
@ -157,17 +160,21 @@ def get_license_data(app, key):
|
|||
bearer_security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
_argon2_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password using bcrypt"""
|
||||
"""Hash a password using the configured algorithm (bcrypt or argon2)."""
|
||||
if PASSWORD_HASH_ALGORITHM == 'argon2':
|
||||
return _argon2_hasher.hash(password)
|
||||
# bcrypt default
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
|
||||
def validate_password(password: str) -> bool:
|
||||
# The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing.
|
||||
if len(password.encode('utf-8')) > 72:
|
||||
raise Exception(
|
||||
ERROR_MESSAGES.PASSWORD_TOO_LONG,
|
||||
)
|
||||
# bcrypt silently truncates passwords longer than 72 bytes; reject them explicitly.
|
||||
if PASSWORD_HASH_ALGORITHM == 'bcrypt' and len(password.encode('utf-8')) > 72:
|
||||
raise Exception(ERROR_MESSAGES.PASSWORD_TOO_LONG)
|
||||
|
||||
if ENABLE_PASSWORD_VALIDATION:
|
||||
if not PASSWORD_VALIDATION_REGEX_PATTERN.match(password):
|
||||
|
|
@ -177,14 +184,18 @@ def validate_password(password: str) -> bool:
|
|||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash"""
|
||||
return (
|
||||
bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8'),
|
||||
)
|
||||
if hashed_password
|
||||
else None
|
||||
"""Verify a password against its hash, auto-detecting bcrypt or argon2."""
|
||||
if not hashed_password:
|
||||
return False
|
||||
if hashed_password.startswith('$argon2'):
|
||||
try:
|
||||
return _argon2_hasher.verify(hashed_password, plain_password)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
return False
|
||||
# Fall back to bcrypt for existing hashes
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8'),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue