Skip to content

Commit b859088

Browse files
authored
Merge pull request #152 from mariodruiz/fix/manage-users-password
Fix: set native user passwords via Hub admin API in manage_users.py
2 parents 211223a + 96f63e4 commit b859088

1 file changed

Lines changed: 107 additions & 93 deletions

File tree

scripts/manage_users.py

Lines changed: 107 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
# Create users from file (with optional password column)
4545
python manage_users.py create users.csv
4646
47-
# Set passwords for users (requires kubectl access)
47+
# Set passwords for users (via admin API)
4848
python manage_users.py set-passwords users.csv
4949
5050
# Delete users from file
@@ -118,6 +118,56 @@ def normalize_username(username: str) -> str:
118118
return username
119119
return username.strip().lower()
120120

121+
def set_password(self, username: str, password: str, force_change: bool = True) -> tuple[bool, str]:
122+
"""
123+
Set password for a native user via the admin API.
124+
125+
Args:
126+
username: Username (without prefix)
127+
password: Password to set
128+
force_change: If True, mark user for forced password change
129+
130+
Returns:
131+
(success, message) tuple
132+
"""
133+
username = self.normalize_username(username)
134+
try:
135+
response = requests.post(
136+
f"{self.hub_url}/hub/admin/api/set-password",
137+
headers=self.headers,
138+
json={"username": username, "password": password, "force_change": force_change},
139+
)
140+
data = response.json()
141+
if response.status_code == 200:
142+
return True, data.get("message", "Password set")
143+
return False, data.get("error", f"HTTP {response.status_code}")
144+
except requests.exceptions.RequestException as e:
145+
return False, str(e)
146+
147+
def batch_set_passwords(self, users: list[dict], force_change: bool = True) -> tuple[bool, dict]:
148+
"""
149+
Set passwords for multiple users in a single API call.
150+
151+
Args:
152+
users: List of dicts with 'username' and 'password' keys
153+
force_change: If True, mark users for forced password change
154+
155+
Returns:
156+
(success, result_dict) tuple
157+
"""
158+
try:
159+
response = requests.post(
160+
f"{self.hub_url}/hub/admin/api/batch-set-password",
161+
headers=self.headers,
162+
json={"users": users, "force_change": force_change},
163+
)
164+
data = response.json()
165+
if response.status_code == 200:
166+
return True, data
167+
return False, data
168+
except requests.exceptions.RequestException as e:
169+
return False, {"error": str(e)}
170+
121171
def _check_connection(self) -> bool:
122172
"""Check if connection to JupyterHub is working"""
123173
try:
@@ -511,75 +561,18 @@ def cmd_set_admin(args, manager: JupyterHubUserManager):
511561

512562

513563
def generate_password(length: int = 12) -> str:
514-
"""Generate a random password"""
515-
alphabet = string.ascii_letters + string.digits
516-
return "".join(secrets.choice(alphabet) for _ in range(length))
517-
518-
519-
def set_password_in_pod(username: str, password: str, force_change: bool = True, namespace: str = "jupyterhub") -> bool:
520-
"""
521-
Set password for a user by executing Python code in the hub pod.
522-
523-
Args:
524-
username: Username (without prefix)
525-
password: Password to set
526-
force_change: If True, mark user for forced password change
527-
namespace: Kubernetes namespace
528-
529-
Returns:
530-
True if successful
531-
"""
532-
# Normalize username to lowercase
533-
username = username.strip().lower()
534-
535-
# Python code to execute in the pod
536-
python_code = f'''
537-
import dbm
538-
import bcrypt
539-
540-
# Password database paths
541-
passwords_dbm = "/srv/jupyterhub/passwords.dbm"
542-
force_change_dbm = "/srv/jupyterhub/force_password_change.dbm"
543-
544-
username = "{username}"
545-
password = "{password}"
546-
force_change = {force_change}
547-
548-
# Set password
549-
with dbm.open(passwords_dbm, "c", 0o600) as db:
550-
db[username] = bcrypt.hashpw(password.encode("utf8"), bcrypt.gensalt())
551-
552-
# Mark for forced password change
553-
if force_change:
554-
with dbm.open(force_change_dbm, "c", 0o600) as db:
555-
db[username] = b"1"
556-
557-
print(f"OK: Password set for {{username}}")
558-
'''
559-
560-
try:
561-
result = subprocess.run(
562-
["kubectl", "--namespace", namespace, "exec", "deployment/hub", "--", "python3", "-c", python_code],
563-
capture_output=True,
564-
text=True,
565-
timeout=30,
566-
)
567-
568-
if result.returncode == 0 and "OK:" in result.stdout:
569-
return True
570-
else:
571-
print(f" Error: {result.stderr or result.stdout}")
572-
return False
573-
574-
except subprocess.TimeoutExpired:
575-
print(f" Timeout setting password for {username}")
576-
return False
577-
except FileNotFoundError:
578-
print(" Error: kubectl not found. Please install kubectl.")
579-
return False
580-
except Exception as e:
581-
print(f" Error: {e}")
582-
return False
564+
"""Generate a random password that meets Hub strength requirements."""
565+
special = "!@#$%^&*_+-="
566+
alphabet = string.ascii_letters + string.digits + special
567+
while True:
568+
password = "".join(secrets.choice(alphabet) for _ in range(length))
569+
if (
570+
any(c in string.ascii_uppercase for c in password)
571+
and any(c in string.ascii_lowercase for c in password)
572+
and any(c in string.digits for c in password)
573+
and any(c in special for c in password)
574+
):
575+
return password
583576

584577

585578
def cmd_set_passwords(args, manager: JupyterHubUserManager):
@@ -594,17 +587,15 @@ def cmd_set_passwords(args, manager: JupyterHubUserManager):
594587
print(" Or add a 'password' column to your file.")
595588
return
596589

597-
results = {"success": 0, "failed": 0}
590+
force_change = not args.no_force_change
598591
output_data = []
599-
600-
print(f"\n🔄 Setting passwords for {len(users)} users...")
592+
batch_entries = []
601593

602594
for user in users:
603595
username = user.get("username", "").strip()
604596
if not username:
605597
continue
606598

607-
# Get or generate password
608599
password = user.get("password", "")
609600
if pd.isna(password) or not password:
610601
if args.generate:
@@ -614,24 +605,50 @@ def cmd_set_passwords(args, manager: JupyterHubUserManager):
614605
continue
615606

616607
password = str(password).strip()
608+
username = manager.normalize_username(username)
609+
batch_entries.append({"username": username, "password": password})
610+
output_data.append({"username": username, "password": password, "force_change": force_change})
617611

618-
# Set password in pod
619-
force_change = not args.no_force_change
620-
success = set_password_in_pod(username, password, force_change, args.namespace)
612+
if not batch_entries:
613+
print("⚠️ No users to process.")
614+
return
621615

622-
if success:
623-
print(f" ✅ Set password for: {username}" + (" (force change)" if force_change else ""))
624-
results["success"] += 1
625-
output_data.append({"username": username, "password": password, "force_change": force_change})
626-
else:
627-
print(f" ❌ Failed: {username}")
628-
results["failed"] += 1
616+
print(f"\n🔄 Setting passwords for {len(batch_entries)} users...")
629617

630-
print("\n" + "=" * 50)
631-
print("📊 Results:")
632-
print(f" ✅ Success: {results['success']}")
633-
print(f" ❌ Failed: {results['failed']}")
634-
print("=" * 50)
618+
success, result = manager.batch_set_passwords(batch_entries, force_change=force_change)
619+
620+
if success:
621+
print(f" ✅ Success: {result.get('success', 0)}")
622+
print(f" ❌ Failed: {result.get('failed', 0)}")
623+
failed_usernames = set()
624+
for entry in result.get("results", []):
625+
if entry.get("status") == "failed":
626+
print(f" {entry.get('username', '?')}: {entry.get('error', 'unknown')}")
627+
failed_usernames.add(entry.get("username"))
628+
if failed_usernames:
629+
output_data = [e for e in output_data if e["username"] not in failed_usernames]
630+
else:
631+
error = result.get("error", "Unknown error")
632+
print(f" ❌ Batch request failed: {error}")
633+
print(" Falling back to per-user API calls...")
634+
635+
succeeded = 0
636+
failed_count = 0
637+
output_data = []
638+
for entry in batch_entries:
639+
ok, msg = manager.set_password(entry["username"], entry["password"], force_change=force_change)
640+
if ok:
641+
print(f" ✅ Set password for: {entry['username']}" + (" (force change)" if force_change else ""))
642+
succeeded += 1
643+
output_data.append(
644+
{"username": entry["username"], "password": entry["password"], "force_change": force_change}
645+
)
646+
else:
647+
print(f" ❌ Failed: {entry['username']}: {msg}")
648+
failed_count += 1
649+
650+
print(f"\n ✅ Success: {succeeded}")
651+
print(f" ❌ Failed: {failed_count}")
635652

636653
# Save output with passwords if requested
637654
if args.output and output_data:
@@ -949,7 +966,7 @@ def main():
949966
admin_parser.add_argument("--revoke", "-r", action="store_true", help="Revoke admin privileges instead of granting")
950967

951968
# Set-passwords command
952-
setpw_parser = subparsers.add_parser("set-passwords", help="Set default passwords for users (requires kubectl)")
969+
setpw_parser = subparsers.add_parser("set-passwords", help="Set default passwords for users via admin API")
953970
setpw_parser.add_argument("file", help="CSV or Excel file with user data")
954971
setpw_parser.add_argument(
955972
"--generate", "-g", action="store_true", help="Generate passwords for users without password column"
@@ -959,9 +976,6 @@ def main():
959976
"--no-force-change", action="store_true", help="Do not require password change on first login"
960977
)
961978
setpw_parser.add_argument("--output", "-o", help="Output file to save usernames and passwords")
962-
setpw_parser.add_argument(
963-
"--namespace", "-n", default="jupyterhub", help="Kubernetes namespace (default: jupyterhub)"
964-
)
965979

966980
# Set-quota command
967981
setquota_parser = subparsers.add_parser("set-quota", help="Set quota for users (requires kubectl)")

0 commit comments

Comments
 (0)