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:
Hammad Rehman 2026-06-03 11:12:18 +01:00
parent 1a97751e37
commit 9dbe0c4275
4 changed files with 28 additions and 24 deletions

View file

@ -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.'

View 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,}$',

View file

@ -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),

View file

@ -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'),
)