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
513563def 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
585578def 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