feat: Enforce optional password policy for users

Given the env variable ENABLE_ENFORCE_PASSWORD_POLICY
is set to "true", the backend will enforce a password policy when
users sign up, change their password or are create throught the api.

The password policy is currently hardcoded to be at least 12 characters,
contain at least one number, one upper and one lowercase letter and one
special character.

The feature is disabled by default to avoid introducing a breaking change.
This commit is contained in:
Sozial-KI 2025-09-22 12:05:10 +02:00
parent fb3b736a35
commit 723c424bd6
6 changed files with 61 additions and 3 deletions

View File

@ -305,6 +305,11 @@ API_KEY_ALLOWED_ENDPOINTS = PersistentConfig(
os.environ.get("API_KEY_ALLOWED_ENDPOINTS", ""),
)
ENABLE_ENFORCE_PASSWORD_POLICY = PersistentConfig(
"ENABLE_ENFORCE_PASSWORD_POLICY",
"auth.password_policy.enable",
os.environ.get("ENABLE_ENFORCE_PASSWORD_POLICY", "False").lower() == "true",
)
JWT_EXPIRES_IN = PersistentConfig(
"JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "-1")

View File

@ -44,6 +44,7 @@ class ERROR_MESSAGES(str, Enum):
)
INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again."
INVALID_EMAIL_FORMAT = "The email format you entered is invalid. Please double-check and make sure you're using a valid email address (e.g., yourname@example.com)."
INVALID_PASSWORD_FORMAT = "The password you entered does not match the criteria. Please include at least 12 characters, including one upper case, one lower case letter, one number and one of the following symbols !@#$%^&*,. ."
INVALID_PASSWORD = (
"The password provided is incorrect. Please check for typos and try again."
)

View File

@ -329,6 +329,7 @@ from open_webui.config import (
ENABLE_API_KEY,
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
API_KEY_ALLOWED_ENDPOINTS,
ENABLE_ENFORCE_PASSWORD_POLICY,
ENABLE_CHANNELS,
ENABLE_NOTES,
ENABLE_COMMUNITY_SHARING,
@ -696,6 +697,8 @@ app.state.config.WEBUI_URL = WEBUI_URL
app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP
app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM
app.state.config.ENABLE_ENFORCE_PASSWORD_POLICY = ENABLE_ENFORCE_PASSWORD_POLICY
app.state.config.ENABLE_API_KEY = ENABLE_API_KEY
app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = (
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS

View File

@ -38,7 +38,11 @@ from fastapi.responses import RedirectResponse, Response, JSONResponse
from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP
from pydantic import BaseModel
from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.misc import (
parse_duration,
validate_email_format,
validate_password_format,
)
from open_webui.utils.auth import (
decode_token,
create_api_key,
@ -164,7 +168,9 @@ async def update_profile(
@router.post("/update/password", response_model=bool)
async def update_password(
form_data: UpdatePasswordForm, session_user=Depends(get_current_user)
form_data: UpdatePasswordForm,
request: Request,
session_user=Depends(get_current_user),
):
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
@ -172,6 +178,13 @@ async def update_password(
user = Auths.authenticate_user(session_user.email, form_data.password)
if user:
if request.app.state.config.ENABLE_ENFORCE_PASSWORD_POLICY:
if not validate_password_format(form_data.password):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.INVALID_PASSWORD_FORMAT,
)
hashed = get_password_hash(form_data.new_password)
return Auths.update_user_password_by_id(user.id, hashed)
else:
@ -586,6 +599,13 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
)
if request.app.state.config.ENABLE_ENFORCE_PASSWORD_POLICY:
if not validate_password_format(form_data.password):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.INVALID_PASSWORD_FORMAT,
)
if Users.get_user_by_email(form_data.email.lower()):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
@ -745,12 +765,21 @@ async def signout(request: Request, response: Response):
@router.post("/add", response_model=SigninResponse)
async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)):
async def add_user(
request: Request, form_data: AddUserForm, user=Depends(get_admin_user)
):
if not validate_email_format(form_data.email.lower()):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
)
if request.app.state.config.ENABLE_ENFORCE_PASSWORD_POLICY:
if not validate_password_format(form_data.password):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.INVALID_PASSWORD_FORMAT,
)
if Users.get_user_by_email(form_data.email.lower()):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)

View File

@ -38,6 +38,7 @@ from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR
from open_webui.utils.auth import get_admin_user, get_password_hash, get_verified_user
from open_webui.utils.access_control import get_permissions, has_permission
from open_webui.utils.misc import validate_password_format
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@ -428,6 +429,7 @@ async def get_user_active_status_by_id(user_id: str, user=Depends(get_verified_u
@router.post("/{user_id}/update", response_model=Optional[UserModel])
async def update_user_by_id(
request: Request,
user_id: str,
form_data: UserUpdateForm,
session_user=Depends(get_admin_user),
@ -470,6 +472,13 @@ async def update_user_by_id(
)
if form_data.password:
if request.app.state.config.ENABLE_ENFORCE_PASSWORD_POLICY:
if not validate_password_format(form_data.password):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.INVALID_PASSWORD_FORMAT,
)
hashed = get_password_hash(form_data.password)
log.debug(f"hashed: {hashed}")
Auths.update_user_password_by_id(user_id, hashed)

View File

@ -300,6 +300,17 @@ def validate_email_format(email: str) -> bool:
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))
def validate_password_format(password: str) -> bool:
# Password must have at least 12 characters
if len(password) < 12:
return False
# Password must have at least one upper case and lower case letter
# one number and one special character
return bool(
re.match(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*,\.]).*$", password)
)
def sanitize_filename(file_name):
# Convert to lowercase
lower_case_file_name = file_name.lower()