diff --git a/src/gam/cmd/users/manage.py b/src/gam/cmd/users/manage.py index 29aea313..168dff89 100644 --- a/src/gam/cmd/users/manage.py +++ b/src/gam/cmd/users/manage.py @@ -15,14 +15,10 @@ from gamlib import gluprop as UProp import base64 import time -from gamlib import glaction from gamlib import glapi as API from gamlib import glcfg as GC -from gamlib import glclargs -from gamlib import glentity from gamlib import glgapi as GAPI from gamlib import glglobals as GM -from gamlib import glindent from gamlib import glmsgs as Msg from gamlib import glskus as SKU from gam.util.access import accessErrorExit, duplicateAliasGroupUserWarning, entityUnknownWarning @@ -96,11 +92,7 @@ from gam.util.tags import ( sendCreateUpdateUserNotification, ) -Act = glaction.GamAction() -Ent = glentity.GamEntity() -Ind = glindent.GamIndent() -Cmd = glclargs.GamCLArgs() - +from gam.var import Act, Cmd, Ent, Ind from secrets import SystemRandom from passlib.hash import sha512_crypt @@ -108,6 +100,14 @@ UTF8 = 'utf-8' UNKNOWN = 'Unknown' +def hashPassword(password): + """Hash a password using SHA-512 crypt for Google's API. + + Returns a tuple of (hashed_password, hash_function_name). + The hash_function_name is always 'crypt' for Google's Directory API. + """ + return (sha512_crypt.hash(password, rounds=10000), 'crypt') + def _getGroupOrgUnitMap(): def getKeyFieldInfo(keyword, defaultField): @@ -255,8 +255,7 @@ class PasswordOptions(): if not self.notifyPasswordSet: notify[up] = body[up] if self.clearPassword else Msg.CONTACT_ADMINISTRATOR_FOR_PASSWORD if self.hashPassword: - body[up] = sha512_crypt.hash(body[up], rounds=10000) - body['hashFunction'] = 'crypt' + body[up], body['hashFunction'] = hashPassword(body[up]) elif self.b64DecryptPassword: if body[up].lower()[:5] in ['{md5}', '{sha}']: body[up] = body[up][5:] diff --git a/tests/test_password.py b/tests/test_password.py new file mode 100644 index 00000000..2db8583f --- /dev/null +++ b/tests/test_password.py @@ -0,0 +1,60 @@ +"""Unit tests for password hashing — verify GAM always produces SHA-512 crypt hashes. + +Tests GAM's actual hashPassword() function from cmd/users/manage.py, +not passlib directly. +""" + +import pytest + + +class TestPasswordHashing: + """Ensure GAM's hashPassword() always generates SHA-512 crypt format hashes. + + GAM sends hashed passwords to Google's Directory API with + hashFunction='crypt'. The hash MUST be SHA-512 ($6$ prefix), + never legacy formats like DES, MD5 ($1$), or SHA-256 ($5$). + """ + + def test_hash_is_sha512_format(self): + """Generated hash must start with $6$ (SHA-512 crypt identifier).""" + from gam.cmd.users.manage import hashPassword + hashed, func = hashPassword('test-password') + assert hashed.startswith('$6$'), ( + f'Expected SHA-512 hash ($6$...) but got: {hashed[:10]}...' + ) + + def test_hash_function_is_crypt(self): + """hashFunction returned must be 'crypt' for Google's API.""" + from gam.cmd.users.manage import hashPassword + _, func = hashPassword('test-password') + assert func == 'crypt' + + def test_hash_contains_rounds(self): + """Generated hash must include the rounds parameter.""" + from gam.cmd.users.manage import hashPassword + hashed, _ = hashPassword('test-password') + assert '$rounds=10000$' in hashed, ( + f'Expected rounds=10000 in hash but got: {hashed[:30]}...' + ) + + def test_hash_verifies(self): + """A generated hash must verify against its source password.""" + from gam.cmd.users.manage import hashPassword + from passlib.hash import sha512_crypt + password = 'correct-horse-battery-staple' + hashed, _ = hashPassword(password) + assert sha512_crypt.verify(password, hashed) + + def test_hash_rejects_wrong_password(self): + """A generated hash must NOT verify against a different password.""" + from gam.cmd.users.manage import hashPassword + from passlib.hash import sha512_crypt + hashed, _ = hashPassword('right-password') + assert not sha512_crypt.verify('wrong-password', hashed) + + def test_different_passwords_produce_different_hashes(self): + """Two different passwords must not produce the same hash.""" + from gam.cmd.users.manage import hashPassword + h1, _ = hashPassword('password-one') + h2, _ = hashPassword('password-two') + assert h1 != h2