diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index afcd4c6..0000000 --- a/.coveragerc +++ /dev/null @@ -1,21 +0,0 @@ -[run] -branch = True -source = - pam -omit = - pam/conftest.py - pam/pam.py - venv/ - -[tool:pytest] -addopts = --cov=pam --cov-report=html - -[report] -skip_empty = True -fail_under = 100 -exclude_lines = - pragma: no cover - if __name__ == .__main__.: - -[html] -directory = htmlcov diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..eccb36b --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,25 @@ +name: Python package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10"] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Tox test + run: | + tox -vv diff --git a/.gitignore b/.gitignore index 65ac6bb..3e15450 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ # Distribution / packaging .Python env/ +.venv/ bin/ build/ develop-eggs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6e3ce2..e5cf7eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,3 +3,6 @@ submitted under the existing project license. This project is switching to a git-flow style, please make pull requests against the `develop` branch. + +Good reading +* https://packaging.python.org/en/latest/tutorials/packaging-projects/ diff --git a/Makefile b/Makefile index 22693ba..11142b5 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,10 @@ VIRTUALENV = $(shell which virtualenv) PYTHONEXEC = python -VERSION = `grep VERSION version.py | cut -d \' -f2` +VERSION = `grep VERSION src/pam/version.py | cut -d \' -f2` -bandit: pydeps - . venv/bin/activate; bandit -r pam/ +build: pydeps + python -m build clean: rm -rf *.egg-info/ @@ -34,28 +34,35 @@ deps: . venv/bin/activate; python -m pip install --upgrade -qr requirements.txt install: clean venv deps - . venv/bin/activate; python setup.py install + . venv/bin/activate; pip install --use-pep517 --progress-bar emoji inspectortiger: pydeps - . venv/bin/activate; inspectortiger pam/ + . venv/bin/activate; inspectortiger src/pam/ lint: pydeps - . venv/bin/activate; python -m flake8 pam/ --max-line-length=120 + . venv/bin/activate; python -m flake8 src/pam/ --max-line-length=120 -preflight: bandit coverage test +preflight: bandit test -pydeps: - . venv/bin/activate; pip install --upgrade -q pip; \ - pip install --upgrade -q pip flake8 bandit \ - pyre-check coverage pytest pytest-mock pytest-cov pytest-runner \ - mock minimock faker responses +publish-pypi-test: clean venv build + . venv/bin/activate; \ + python3 -m pip install --upgrade twine && \ + python3 -m twine upload --repository testpypi dist/* + +publish-pypi: clean venv build + . venv/bin/activate; \ + python3 -m pip install --upgrade twine && \ + python3 -m twine upload --repository pypi dist/* -test: pydeps deps venv lint +pydeps: . venv/bin/activate; \ - pytest tests -r w --capture=sys -vvv --cov; \ - coverage html + pip install --upgrade -q pip && \ + pip install --upgrade -q pip build + +test: tox tox: + rm -fr .tox . venv/bin/activate; tox venv: diff --git a/pam/conftest.py b/pam/conftest.py deleted file mode 100644 index ac07420..0000000 --- a/pam/conftest.py +++ /dev/null @@ -1 +0,0 @@ -# trigger pytest diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..83d8bee --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,71 @@ +[build-system] +requires = [ + 'setuptools>=44', + 'wheel>=0.30.0', + 'six', +] +build-backend = 'setuptools.build_meta' + +# ignore the tox documentation, it IS NOT supported yet +# https://github.com/tox-dev/tox/issues/2148 +#[tox] +#isolated_build = true + +[tool.tox] +legacy_tox_ini = """ +[tox] +envlist = py310 +isolated_build = true +#skipsdist = true + +[gh-actions] +python = + 3.7: py37 + 3.8: py38 + 3.9: py39 + 3.10: py310 + +[testenv] +basepython = python3.10 +passenv = * +deps = + bandit + flake8 + mypy + types-six + coverage + pytest-cov + pytest + -rrequirements.txt + +commands = + flake8 src/pam/ + mypy + bandit -r src -c "pyproject.toml" + pytest --cov -r w --capture=sys -vvv --cov-report=html +""" + +[tool.bandit] +exclude_dirs = ["./venv", "./test", ] +recursive = true + +[tool.mypy] +files = ["src/pam/__init__.py", "src/pam/__internals.py"] +ignore_missing_imports = true + +[tool.pytest] +python_files = "test_*.py" +norecursedirs = ".tox" + +[tool.coverage.run] +branch = true +# awkward how I can include "pam" but I have to be incredibly specific when omitting +source = ["pam"] +omit = ["*/pam/pam.py", "*/pam/version.py",] + +[tool.coverage.html] +directory = "htmlcov" + +[tool.coverage.report] +skip_empty = true +fail_under = 100 diff --git a/requirements.txt b/requirements.txt index ffe2fce..b2b4ddd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ six +toml diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8514037 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,44 @@ +[metadata] +name = python-pam +version = attr: pam.version.VERSION +author = David Ford +author_email = david@blue-labs.org +description = Python PAM module using ctypes, py3 +long_description = file: README.md +long_description_content_type = text/markdown +license = License :: OSI Approved :: MIT License +url = https://github.com/FirefighterBlu3/python-pam +project_urls = + Bug Tracker = https://github.com/FirefighterBlu3/python-pam/issues +classifiers = + Development Status :: 6 - Mature + Environment :: Plugins + Intended Audience :: Developers + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: MIT License + Operating System :: POSIX + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 2 + Programming Language :: Python :: 3 + Topic :: Security + Topic :: System :: Systems Administration :: Authentication/Directory + +[options] +packages = find: +package_dir = + = src + +[options.packages.find] +where = src + +[sdist] +keep_temp = 1 + +[build_ext] +debug = 1 + +[flake8] +max-line-length = 120 + diff --git a/setup.py b/setup.py deleted file mode 100644 index 9f3b6bc..0000000 --- a/setup.py +++ /dev/null @@ -1,40 +0,0 @@ -import os -from setuptools import setup, find_packages - - -def read(fname): - return open(os.path.join(os.path.dirname(__file__), fname)).read() - - -sdesc = 'Python PAM module using ctypes, py3/py2' - -setup(name = 'python-pam', - description = sdesc, - long_description = read('README.md'), - long_description_content_type='text/markdown', - packages = find_packages(exclude=['tests']), - version = '2.0.0rc1', - author = 'David Ford', - author_email = 'david@blue-labs.org', - maintainer = 'David Ford', - maintainer_email = 'david@blue-labs.org', - url = 'https://github.com/FirefighterBlu3/python-pam', - download_url = 'https://github.com/FirefighterBlu3/python-pam', - license = 'License :: OSI Approved :: MIT License', - platforms = ['i686', 'x86_64'], - classifiers = [ - 'Development Status :: 6 - Mature', - 'Environment :: Plugins', - 'Intended Audience :: Developers', - 'Intended Audience :: Information Technology', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: MIT License', - 'Operating System :: POSIX', - 'Operating System :: POSIX :: Linux', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 3', - 'Topic :: Security', - 'Topic :: System :: Systems Administration :: Authentication/Directory', - ], - ) diff --git a/pam/__init__.py b/src/pam/__init__.py similarity index 88% rename from pam/__init__.py rename to src/pam/__init__.py index 374ef9d..0c6f9e0 100644 --- a/pam/__init__.py +++ b/src/pam/__init__.py @@ -116,14 +116,22 @@ __PA = None -def authenticate(*args, **kvargs): +def authenticate(username, + password, + service='login', + env=None, + call_end=True, + encoding='utf-8', + resetcreds=True, + print_failure_messages=False): global __PA if __PA is None: # pragma: no branch __PA = PamAuthenticator() - return __PA.authenticate(*args, **kvargs) + return __PA.authenticate(username, password, service, env, call_end, encoding, resetcreds, print_failure_messages) # legacy implementations used pam.pam() -pam = authenticate +pam = PamAuthenticator +authenticate.__doc__ = PamAuthenticator.authenticate.__doc__ diff --git a/pam/__internals.py b/src/pam/__internals.py similarity index 66% rename from pam/__internals.py rename to src/pam/__internals.py index e543038..e069d76 100644 --- a/pam/__internals.py +++ b/src/pam/__internals.py @@ -1,7 +1,7 @@ import os import six import sys -import ctypes +from ctypes import cdll from ctypes import CFUNCTYPE from ctypes import CDLL from ctypes import POINTER @@ -9,6 +9,7 @@ from ctypes import byref from ctypes import cast from ctypes import sizeof +from ctypes import py_object from ctypes import c_char from ctypes import c_char_p from ctypes import c_int @@ -16,6 +17,7 @@ from ctypes import c_void_p from ctypes import memmove from ctypes.util import find_library + try: from typing import Union except ImportError: @@ -73,27 +75,64 @@ PAM_USER_UNKNOWN = 10 PAM_XDISPLAY = 11 -__all__ = ('PAM_ABORT', 'PAM_ACCT_EXPIRED', 'PAM_AUTHINFO_UNAVAIL', - 'PAM_AUTHTOK_DISABLE_AGING', 'PAM_AUTHTOK_ERR', - 'PAM_AUTHTOK_EXPIRED', 'PAM_AUTHTOK_LOCK_BUSY', - 'PAM_AUTHTOK_RECOVER_ERR', 'PAM_AUTH_ERR', 'PAM_BAD_ITEM', - 'PAM_BUF_ERR', 'PAM_CHANGE_EXPIRED_AUTHTOK', 'PAM_CONV', - 'PAM_CONV_ERR', 'PAM_CRED_ERR', 'PAM_CRED_EXPIRED', - 'PAM_CRED_INSUFFICIENT', 'PAM_CRED_UNAVAIL', 'PAM_DATA_SILENT', - 'PAM_DELETE_CRED', 'PAM_DISALLOW_NULL_AUTHTOK', 'PAM_ERROR_MSG', - 'PAM_ESTABLISH_CRED', 'PAM_IGNORE', 'PAM_MAXTRIES', - 'PAM_MODULE_UNKNOWN', 'PAM_NEW_AUTHTOK_REQD', 'PAM_NO_MODULE_DATA', - 'PAM_OPEN_ERR', 'PAM_PERM_DENIED', 'PAM_PROMPT_ECHO_OFF', - 'PAM_PROMPT_ECHO_ON', 'PAM_REFRESH_CRED', 'PAM_REINITIALIZE_CRED', - 'PAM_RHOST', 'PAM_RUSER', 'PAM_SERVICE', 'PAM_SERVICE_ERR', - 'PAM_SESSION_ERR', 'PAM_SILENT', 'PAM_SUCCESS', 'PAM_SYMBOL_ERR', - 'PAM_SYSTEM_ERR', 'PAM_TEXT_INFO', 'PAM_TRY_AGAIN', 'PAM_TTY', - 'PAM_USER', 'PAM_USER_PROMPT', 'PAM_USER_UNKNOWN', - 'PamAuthenticator') + +__all__ = ( + "PAM_ABORT", + "PAM_ACCT_EXPIRED", + "PAM_AUTHINFO_UNAVAIL", + "PAM_AUTHTOK_DISABLE_AGING", + "PAM_AUTHTOK_ERR", + "PAM_AUTHTOK_EXPIRED", + "PAM_AUTHTOK_LOCK_BUSY", + "PAM_AUTHTOK_RECOVER_ERR", + "PAM_AUTH_ERR", + "PAM_BAD_ITEM", + "PAM_BUF_ERR", + "PAM_CHANGE_EXPIRED_AUTHTOK", + "PAM_CONV", + "PAM_CONV_ERR", + "PAM_CRED_ERR", + "PAM_CRED_EXPIRED", + "PAM_CRED_INSUFFICIENT", + "PAM_CRED_UNAVAIL", + "PAM_DATA_SILENT", + "PAM_DELETE_CRED", + "PAM_DISALLOW_NULL_AUTHTOK", + "PAM_ERROR_MSG", + "PAM_ESTABLISH_CRED", + "PAM_IGNORE", + "PAM_MAXTRIES", + "PAM_MODULE_UNKNOWN", + "PAM_NEW_AUTHTOK_REQD", + "PAM_NO_MODULE_DATA", + "PAM_OPEN_ERR", + "PAM_PERM_DENIED", + "PAM_PROMPT_ECHO_OFF", + "PAM_PROMPT_ECHO_ON", + "PAM_REFRESH_CRED", + "PAM_REINITIALIZE_CRED", + "PAM_RHOST", + "PAM_RUSER", + "PAM_SERVICE", + "PAM_SERVICE_ERR", + "PAM_SESSION_ERR", + "PAM_SILENT", + "PAM_SUCCESS", + "PAM_SYMBOL_ERR", + "PAM_SYSTEM_ERR", + "PAM_TEXT_INFO", + "PAM_TRY_AGAIN", + "PAM_TTY", + "PAM_USER", + "PAM_USER_PROMPT", + "PAM_USER_UNKNOWN", + "PamAuthenticator", +) class PamHandle(Structure): """wrapper class for pam_handle_t pointer""" + _fields_ = [("handle", c_void_p)] def __init__(self): @@ -106,43 +145,97 @@ def __repr__(self): class PamMessage(Structure): """wrapper class for pam_message structure""" + _fields_ = [("msg_style", c_int), ("msg", c_char_p)] def __repr__(self): - return "" % (self.msg_style, self.msg) + return "" % (self.msg_style, self.msg) class PamResponse(Structure): """wrapper class for pam_response structure""" + _fields_ = [("resp", c_char_p), ("resp_retcode", c_int)] def __repr__(self): - return "" % (self.resp_retcode, self.resp) + return "" % (self.resp_retcode, self.resp) + + +conv_func = CFUNCTYPE( + c_int, c_int, POINTER(POINTER(PamMessage)), POINTER(POINTER(PamResponse)), c_void_p +) + + +def my_conv( + n_messages, + messages, + p_response, + libc, + msg_list: list, + password: bytes, + encoding: str, +): + """Simple conversation function that responds to any + prompt where the echo is off with the supplied password""" + # Create an array of n_messages response objects + calloc = libc.calloc + calloc.restype = c_void_p + calloc.argtypes = [c_size_t, c_size_t] + + cpassword = c_char_p(password) + + """ + PAM_PROMPT_ECHO_OFF = 1 + PAM_PROMPT_ECHO_ON = 2 + PAM_ERROR_MSG = 3 + PAM_TEXT_INFO = 4 + """ + + addr = calloc(n_messages, sizeof(PamResponse)) + response = cast(addr, POINTER(PamResponse)) + p_response[0] = response + + for i in range(n_messages): + message = messages[i].contents.msg + if sys.version_info >= (3,): # pragma: no branch + message = message.decode(encoding) + msg_list.append(message) -conv_func = CFUNCTYPE(c_int, - c_int, - POINTER(POINTER(PamMessage)), - POINTER(POINTER(PamResponse)), - c_void_p) + if messages[i].contents.msg_style == PAM_PROMPT_ECHO_OFF: + if i == 0: + dst = calloc(len(password) + 1, sizeof(c_char)) + memmove(dst, cpassword, len(password)) + response[i].resp = dst + else: + # void out the message + response[i].resp = None + + response[i].resp_retcode = 0 + + return PAM_SUCCESS class PamConv(Structure): """wrapper class for pam_conv structure""" + _fields_ = [("conv", conv_func), ("appdata_ptr", c_void_p)] class PamAuthenticator: code = 0 - reason = None + reason = None # type: Union[str, bytes, None] def __init__(self): # use a trick of dlopen(), this effectively becomes # dlopen("", ...) which opens our own executable. since 'python' has # a libc dependency, this means libc symbols are already available # to us - libc = ctypes.cdll.LoadLibrary(None) + # libc = CDLL(find_library("c")) + libc = cdll.LoadLibrary(None) + self.libc = libc + libpam = CDLL(find_library("pam")) libpam_misc = CDLL(find_library("pam_misc")) @@ -155,15 +248,19 @@ def __init__(self): # bug #6 (@NIPE-SYSTEMS), some libpam versions don't include this # function - if hasattr(libpam, 'pam_end'): # pragma: no branch + if hasattr(libpam, "pam_end"): # pragma: no branch self.pam_end = libpam.pam_end self.pam_end.restype = c_int self.pam_end.argtypes = [PamHandle, c_int] self.pam_start = libpam.pam_start self.pam_start.restype = c_int - self.pam_start.argtypes = [c_char_p, c_char_p, POINTER(PamConv), - POINTER(PamHandle)] + self.pam_start.argtypes = [ + c_char_p, + c_char_p, + POINTER(PamConv), + POINTER(PamHandle), + ] self.pam_acct_mgmt = libpam.pam_acct_mgmt self.pam_acct_mgmt.restype = c_int @@ -197,8 +294,7 @@ def __init__(self): if libpam_misc._name: # pragma: no branch self.pam_misc_setenv = libpam_misc.pam_misc_setenv self.pam_misc_setenv.restype = c_int - self.pam_misc_setenv.argtypes = [PamHandle, c_char_p, c_char_p, - c_int] + self.pam_misc_setenv.argtypes = [PamHandle, c_char_p, c_char_p, c_int] self.pam_getenv = libpam.pam_getenv self.pam_getenv.restype = c_char_p @@ -209,24 +305,16 @@ def __init__(self): self.pam_getenvlist.argtypes = [PamHandle] def authenticate( - self, - username, - password, - service='login', - env=None, - call_end=True, - encoding='utf-8', - resetcreds=True, - print_failure_messages=False): - self.pam_authenticate.__annotations = {'username': str, - 'password': str, - 'service': str, - 'env': dict, - 'call_end': bool, - 'encoding': str, - 'resetcreds': bool, - 'return': bool, - 'print_failure_messages': bool} + self, + username, # type: Union[str, bytes] + password, # type: Union[str, bytes] + service="login", # type: Union[str, bytes] + env=None, # type: dict + call_end=True, # type: bool + encoding="utf-8", # type: str + resetcreds=True, # type: bool + print_failure_messages=False, # type: bool + ): # type: (...) -> bool """username and password authentication for the given service. Returns True for success, or False for failure. @@ -239,45 +327,35 @@ def authenticate( necessary conversions using the supplied encoding. Args: - username: username to authenticate - password: password in plain text - service: PAM service to authenticate against, defaults to 'login' - env: Pam environment variables - call_end: call the pam_end() function after (default true) - print_failure_messages: Print messages on failure + username (str): username to authenticate + password (str): password in plain text + service (str): PAM service to authenticate against, defaults to 'login' + env (dict): Pam environment variables + call_end (bool): call the pam_end() function after (default true) + print_failure_messages (bool): Print messages on failure Returns: - success: True + success: PAM_SUCCESS failure: False """ @conv_func - def my_conv(n_messages, messages, p_response, app_data): - """Simple conversation function that responds to any - prompt where the echo is off with the supplied password""" - # Create an array of n_messages response objects - addr = self.calloc(n_messages, sizeof(PamResponse)) - response = cast(addr, POINTER(PamResponse)) - p_response[0] = response - - for i in range(n_messages): - message = messages[i].contents.msg - if sys.version_info >= (3,): # pragma: no branch - message = message.decode(encoding) - - self.messages.append(message) - - if messages[i].contents.msg_style == PAM_PROMPT_ECHO_OFF: # pragma: no branch - if i == 0: # pragma: no branch - dst = self.calloc(len(password)+1, sizeof(c_char)) - memmove(dst, cpassword, len(password)) - response[i].resp = dst - else: # pragma: no cover - response[i].resp = None - - response[i].resp_retcode = 0 - - return PAM_SUCCESS + def __conv(n_messages, messages, p_response, app_data): + pyob = cast(app_data, py_object).value + + msg_list = pyob.get("msgs") + password = pyob.get("password") + encoding = pyob.get("encoding") + + return my_conv( + n_messages, + messages, + p_response, + self.libc, + msg_list, + password, + encoding, + ) if isinstance(username, six.text_type): username = username.encode(encoding) @@ -286,28 +364,27 @@ def my_conv(n_messages, messages, p_response, app_data): if isinstance(service, six.text_type): service = service.encode(encoding) - if b'\x00' in username or b'\x00' in password or b'\x00' in service: + if b"\x00" in username or b"\x00" in password or b"\x00" in service: self.code = PAM_SYSTEM_ERR - self.reason = ('none of username, password, or service may contain' - ' NUL') + self.reason = "none of username, password, or service may contain" " NUL" raise ValueError(self.reason) # do this up front so we can safely throw an exception if there's # anything wrong with it - cpassword = c_char_p(password) + app_data = {"msgs": self.messages, "password": password, "encoding": encoding} + conv = PamConv(__conv, c_void_p.from_buffer(py_object(app_data))) self.handle = PamHandle() - conv = PamConv(my_conv, 0) - retval = self.pam_start(service, username, byref(conv), - byref(self.handle)) + retval = self.pam_start(service, username, byref(conv), byref(self.handle)) if retval != PAM_SUCCESS: # pragma: no cover # This is not an authentication error, something has gone wrong # starting up PAM self.code = retval - self.reason = ("pam_start() failed: %s" % - self.pam_strerror(self.handle, retval)) - return retval + self.reason = "pam_start() failed: %s" % self.pam_strerror( + self.handle, retval + ) + return False # set the TTY, required when pam_securetty is used and the username # root is used note: this is only needed WHEN the pam_securetty.so @@ -323,16 +400,16 @@ def my_conv(n_messages, messages, p_response, app_data): # if X $DISPLAY is set, use it - otherwise if we have a STDIN tty, # get it - ctty = os.environ.get('DISPLAY') + ctty = os.environ.get("DISPLAY") if not ctty and os.isatty(0): ctty = os.ttyname(0) # ctty can be invalid if no tty is being used - if ctty: # pragma: no branch - ctty = c_char_p(ctty.encode(encoding)) + if ctty: # pragma: no branch (we don't test a void tty yet) + ctty_p = c_char_p(ctty.encode(encoding)) - retval = self.pam_set_item(self.handle, PAM_TTY, ctty) - retval = self.pam_set_item(self.handle, PAM_XDISPLAY, ctty) + retval = self.pam_set_item(self.handle, PAM_TTY, ctty_p) + retval = self.pam_set_item(self.handle, PAM_XDISPLAY, ctty_p) # Set the environment variables if they were supplied if env: @@ -340,9 +417,9 @@ def my_conv(n_messages, messages, p_response, app_data): raise TypeError('"env" must be a dict') for key, value in env.items(): - if isinstance(key, bytes) and b'\x00' in key: + if isinstance(key, bytes) and b"\x00" in key: raise ValueError('"env{}" key cannot contain NULLs') - if isinstance(value, bytes) and b'\x00' in value: + if isinstance(value, bytes) and b"\x00" in value: raise ValueError('"env{}" value cannot contain NULLs') name_value = "{}={}".format(key, value) @@ -350,36 +427,36 @@ def my_conv(n_messages, messages, p_response, app_data): auth_success = self.pam_authenticate(self.handle, 0) - # skip code coverage, this can only succeed when TEST_* is supplied - # in the environment. we ostensibly know it will work if auth_success == PAM_SUCCESS: - auth_success = self.pam_acct_mgmt(self.handle, 0) # pragma: no cover + auth_success = self.pam_acct_mgmt(self.handle, 0) if auth_success == PAM_SUCCESS and resetcreds: - auth_success = self.pam_setcred(self.handle, PAM_REINITIALIZE_CRED) # pragma: no cover + auth_success = self.pam_setcred(self.handle, PAM_REINITIALIZE_CRED) # store information to inform the caller why we failed self.code = auth_success self.reason = self.pam_strerror(self.handle, auth_success) - if sys.version_info >= (3,): # pragma: no branch - self.reason = self.reason.decode(encoding) + if sys.version_info >= ( + 3, + ): # pragma: no branch (we don't test non-py3 versions) + self.reason = self.reason.decode(encoding) # type: ignore - if call_end and hasattr(self, 'pam_end'): # pragma: no branch + if call_end and hasattr(self, "pam_end"): # pragma: no branch self.pam_end(self.handle, auth_success) self.handle = None if print_failure_messages and self.code != PAM_SUCCESS: print("Failure: {}".format(self.reason)) - return auth_success + return auth_success == PAM_SUCCESS def end(self): """A direct call to pam_end() Returns: Linux-PAM return value as int """ - if not self.handle or not hasattr(self, 'pam_end'): + if not self.handle or not hasattr(self, "pam_end"): return PAM_SYSTEM_ERR retval = self.pam_end(self.handle, self.code) @@ -387,7 +464,7 @@ def end(self): return retval - def open_session(self, encoding='utf-8'): + def open_session(self, encoding="utf-8"): """Call pam_open_session as required by the pam_api Returns: Linux-PAM return value as int @@ -404,7 +481,7 @@ def open_session(self, encoding='utf-8'): return retval - def close_session(self, encoding='utf-8'): + def close_session(self, encoding="utf-8"): """Call pam_close_session as required by the pam_api Returns: Linux-PAM return value as int @@ -421,7 +498,7 @@ def close_session(self, encoding='utf-8'): return retval - def misc_setenv(self, name, value, readonly, encoding='utf-8'): + def misc_setenv(self, name, value, readonly, encoding="utf-8"): """A wrapper for the pam_misc_setenv function Args: name: key name of the environment variable @@ -432,12 +509,11 @@ def misc_setenv(self, name, value, readonly, encoding='utf-8'): if not self.handle or not hasattr(self, "pam_misc_setenv"): return PAM_SYSTEM_ERR - return self.pam_misc_setenv(self.handle, - name.encode(encoding), - value.encode(encoding), - readonly) + return self.pam_misc_setenv( + self.handle, name.encode(encoding), value.encode(encoding), readonly + ) - def putenv(self, name_value, encoding='utf-8'): + def putenv(self, name_value, encoding="utf-8"): """A wrapper for the pam_putenv function Args: name_value: environment variable in the format KEY=VALUE @@ -456,7 +532,7 @@ def putenv(self, name_value, encoding='utf-8'): return retval - def getenv(self, key, encoding='utf-8'): + def getenv(self, key, encoding="utf-8"): """A wrapper for the pam_getenv function Args: key name of the environment variable @@ -467,7 +543,7 @@ def getenv(self, key, encoding='utf-8'): return PAM_SYSTEM_ERR # can't happen unless someone is using internals directly - if sys.version_info >= (3, ): # pragma: no branch + if sys.version_info >= (3,): # pragma: no branch if isinstance(key, six.text_type): # pragma: no branch key = key.encode(encoding) @@ -484,7 +560,7 @@ def getenv(self, key, encoding='utf-8'): return value - def getenvlist(self, encoding='utf-8'): + def getenvlist(self, encoding="utf-8"): """A wrapper for the pam_getenvlist function Returns: environment as python dictionary diff --git a/pam/pam.py b/src/pam/pam.py similarity index 93% rename from pam/pam.py rename to src/pam/pam.py index ad0d869..f16bf0c 100644 --- a/pam/pam.py +++ b/src/pam/pam.py @@ -43,7 +43,7 @@ def hook(): readline.redisplay() readline.set_pre_input_hook(hook) - result = six.input(prompt) # nosec (bandit; python2) + result = six.moves.input(prompt) # nosec (bandit; python2) readline.set_pre_input_hook() @@ -68,10 +68,6 @@ def hook(): value = __pam.getenv(key) print("Pam Environment item: {}={}".format(key, value)) - key = "asdf" - value = __pam.getenv(key) - print("Missing Pam Environment item: {}={}".format(key, value)) - if __pam.code == __internals.PAM_SUCCESS: result = __pam.open_session() print('Open session: {} ({})'.format(__pam.reason, __pam.code)) diff --git a/src/pam/version.py b/src/pam/version.py new file mode 100644 index 0000000..b74741f --- /dev/null +++ b/src/pam/version.py @@ -0,0 +1,3 @@ +VERSION = '2.0.2' +AUTHOR = 'David Ford ' +RELEASED = '2022 March 17' diff --git a/tests/test_internals.py b/tests/test_internals.py index cffd0b8..271e5d9 100644 --- a/tests/test_internals.py +++ b/tests/test_internals.py @@ -1,28 +1,73 @@ import os import pytest +# from pytest import monkeypatch +from ctypes import cdll from ctypes import c_void_p +from ctypes import pointer from pam.__internals import PAM_SYSTEM_ERR from pam.__internals import PAM_SUCCESS from pam.__internals import PAM_SESSION_ERR from pam.__internals import PAM_AUTH_ERR from pam.__internals import PAM_USER_UNKNOWN +from pam.__internals import PAM_PROMPT_ECHO_OFF +from pam.__internals import PAM_PROMPT_ECHO_ON from pam.__internals import PamConv from pam.__internals import PamHandle from pam.__internals import PamMessage from pam.__internals import PamResponse from pam.__internals import PamAuthenticator +from pam.__internals import my_conv -# In order to run some tests, we need a working user/pass combo -# you can specify these on the command line -TEST_USERNAME = os.getenv('USERNAME', '') -TEST_PASSWORD = os.getenv('PASSWORD', '') + +class MockPam: + def __init__(self, og): + self.og = og + self.og_pam_start = og.pam_start + self.PA_authenticate = og.authenticate + self.username = None + self.password = None + + def authenticate(self, *args, **kwargs): + if len(args) > 0: + self.username = args[0] + if len(args) > 1: + self.password = args[1] + self.service = kwargs.get('service') + return self.PA_authenticate(*args, **kwargs) + + def pam_start(self, service, username, conv, handle): + rv = self.og_pam_start(service, username, conv, handle) + return rv + + def pam_authenticate(self, handle, flags): + if isinstance(self.username, str): + self.username = self.username.encode() + if isinstance(self.password, str): + self.password = self.password.encode() + + if self.username == b'good_username' and self.password == b'good_password': + return PAM_SUCCESS + + if self.username == b'unknown_username': + return PAM_USER_UNKNOWN + + return PAM_AUTH_ERR + + def pam_acct_mgmt(self, handle, flags): + # we don't test anything here (yet) + return PAM_SUCCESS @pytest.fixture -def pam_obj(request): +def pam_obj(request, monkeypatch): obj = PamAuthenticator() + MP = MockPam(obj) + monkeypatch.setattr(obj, 'authenticate', MP.authenticate) + monkeypatch.setattr(obj, 'pam_start', MP.pam_start) + monkeypatch.setattr(obj, 'pam_authenticate', MP.pam_authenticate) + monkeypatch.setattr(obj, 'pam_acct_mgmt', MP.pam_acct_mgmt) yield obj @@ -41,12 +86,12 @@ def test_PamMessage__repr(): x.msg_style = 1 x.msg = b'1' str(x) - assert "" == repr(x) + assert "" == repr(x) def test_PamResponse__repr(): x = PamResponse() - assert "" == repr(x) + assert "" == repr(x) def test_PamAuthenticator__setup(): @@ -76,63 +121,49 @@ def test_PamAuthenticator__requires_service_no_nulls(pam_obj): # TEST_* require a valid account def test_PamAuthenticator__normal_success(pam_obj): - if not (TEST_USERNAME and TEST_PASSWORD): - pytest.skip("test requires valid TEST_USERNAME and TEST_PASSWORD set in environment") - - rv = pam_obj.authenticate(TEST_USERNAME, TEST_PASSWORD) - assert PAM_SUCCESS == rv + rv = pam_obj.authenticate('good_username', 'good_password') + assert True is rv def test_PamAuthenticator__normal_password_failure(pam_obj): - if not (TEST_USERNAME and TEST_PASSWORD): - pytest.skip("test requires valid TEST_USERNAME and TEST_PASSWORD set in environment") - - rv = pam_obj.authenticate(TEST_USERNAME, 'not-valid') - assert PAM_AUTH_ERR == rv + rv = pam_obj.authenticate('good_username', 'bad_password') + assert False is rv + assert PAM_AUTH_ERR == pam_obj.code def test_PamAuthenticator__normal_unknown_username(pam_obj): - rv = pam_obj.authenticate('bad_user_name', '') - assert PAM_AUTH_ERR == rv + rv = pam_obj.authenticate('unknown_username', '') + assert False is rv + assert PAM_USER_UNKNOWN == pam_obj.code def test_PamAuthenticator__unset_DISPLAY(pam_obj): os.environ['DISPLAY'] = '' - rv = pam_obj.authenticate(TEST_USERNAME, TEST_PASSWORD) - - # yes, this is intentional. this lets us run code coverage on the - # affected area even though we know the assert would have failed - if not (TEST_USERNAME and TEST_PASSWORD): - pytest.skip("test requires valid TEST_USERNAME and TEST_PASSWORD set in environment") - - assert PAM_SUCCESS == rv + rv = pam_obj.authenticate('good_username', 'good_password') + assert True is rv + assert PAM_SUCCESS == pam_obj.code def test_PamAuthenticator__env_requires_dict(pam_obj): with pytest.raises(TypeError): - pam_obj.authenticate(TEST_USERNAME, TEST_PASSWORD, env='value') + pam_obj.authenticate('good_username', 'good_password', env='value') def test_PamAuthenticator__env_requires_key_no_nulls(pam_obj): with pytest.raises(ValueError): - pam_obj.authenticate(TEST_USERNAME, TEST_PASSWORD, env={b'\x00invalid_key': b'value'}) + pam_obj.authenticate('good_username', 'good_password', env={b'\x00invalid_key': b'value'}) def test_PamAuthenticator__env_requires_value_no_nulls(pam_obj): with pytest.raises(ValueError): - pam_obj.authenticate(TEST_USERNAME, TEST_PASSWORD, env={b'key': b'\x00invalid_value'}) + pam_obj.authenticate('good_username', 'good_password', env={b'key': b'\x00invalid_value'}) def test_PamAuthenticator__env_set(pam_obj): - rv = pam_obj.authenticate(TEST_USERNAME, TEST_PASSWORD, env={'key': b'value'}) - - # yes, this is intentional. this lets us run code coverage on the - # affected area even though we know the assert would have failed - if not (TEST_USERNAME and TEST_PASSWORD): - pytest.skip("test requires valid TEST_USERNAME and TEST_PASSWORD set in environment") - - assert PAM_SUCCESS == rv + rv = pam_obj.authenticate('good_username', 'good_password', env={'key': b'value'}) + assert True is rv + assert PAM_SUCCESS == pam_obj.code def test_PamAuthenticator__putenv_incomplete_setup(pam_obj): @@ -284,3 +315,143 @@ def test_PamAuthenticator__close_session_unauthenticated(pam_obj): pam_obj.pam_start(b'', b'', pam_conv, pam_obj.handle) rv = pam_obj.close_session() assert PAM_SESSION_ERR == rv + + +def test_PamAuthenticator__conversation_callback_prompt_echo_off(pam_obj): + '''Verify that the password is stuffed into the pp_response structure and the + response code is set to zero + ''' + n_messages = 1 + + messages = PamMessage(PAM_PROMPT_ECHO_OFF, b'Password: ') + pp_messages = pointer(pointer(messages)) + + response = PamResponse(b'overwrite', -1) + pp_response = pointer(pointer(response)) + + encoding = 'utf-8' + password = b'blank' + msg_list = [] + + libc = cdll.LoadLibrary(None) + + rv = my_conv(n_messages, + pp_messages, + pp_response, + libc, + msg_list, + password, + encoding) + + assert b'blank' == pp_response.contents.contents.resp + assert 0 == pp_response.contents.contents.resp_retcode + assert PAM_SUCCESS == rv + + +def test_PamAuthenticator__conversation_callback_prompt_echo_on(pam_obj): + '''Verify that the stuffed PamResponse "overwrite" is copied into the output + and the resp_retcode is set to zero + ''' + n_messages = 1 + + messages = PamMessage(PAM_PROMPT_ECHO_ON, b'Password: ') + pp_messages = pointer(pointer(messages)) + + response = PamResponse(b'overwrite', -1) + pp_response = pointer(pointer(response)) + + encoding = 'utf-8' + password = b'blank' + msg_list = [] + + libc = cdll.LoadLibrary(None) + + rv = my_conv(n_messages, + pp_messages, + pp_response, + libc, + msg_list, + password, + encoding) + + assert None is pp_response.contents.contents.resp + assert 0 == pp_response.contents.contents.resp_retcode + assert PAM_SUCCESS == rv + + +def test_PamAuthenticator__conversation_callback_multimessage_OFF_ON(pam_obj): + '''Verify that the stuffed PamResponse "overwrite" is copied into the output + and the resp_retcode is set to zero + ''' + n_messages = 2 + + msg1 = PamMessage(PAM_PROMPT_ECHO_OFF, b'overwrite with PAM_PROMPT_ECHO_OFF') + msg2 = PamMessage(PAM_PROMPT_ECHO_ON, b'overwrite with PAM_PROMPT_ECHO_ON') + + ptr1 = pointer(msg1) + ptr2 = pointer(msg2) + + ptrs = pointer(ptr1) + ptrs[1] = ptr2 + + pp_messages = pointer(ptrs[0]) + + response = PamResponse(b'overwrite', -1) + pp_response = pointer(pointer(response)) + + encoding = 'utf-8' + password = b'blank' + msg_list = [] + + libc = cdll.LoadLibrary(None) + + rv = my_conv(n_messages, + pp_messages, + pp_response, + libc, + msg_list, + password, + encoding) + + assert b'blank' == pp_response.contents.contents.resp + assert 0 == pp_response.contents.contents.resp_retcode + assert PAM_SUCCESS == rv + + +def test_PamAuthenticator__conversation_callback_multimessage_ON_OFF(pam_obj): + '''Verify that the stuffed PamResponse "overwrite" is copied into the output + and the resp_retcode is set to zero + ''' + n_messages = 2 + + msg1 = PamMessage(PAM_PROMPT_ECHO_ON, b'overwrite with PAM_PROMPT_ECHO_ON') + msg2 = PamMessage(PAM_PROMPT_ECHO_OFF, b'overwrite with PAM_PROMPT_ECHO_OFF') + + ptr1 = pointer(msg1) + ptr2 = pointer(msg2) + + ptrs = pointer(ptr1) + ptrs[1] = ptr2 + + pp_messages = pointer(ptrs[0]) + + response = PamResponse(b'overwrite', -1) + pp_response = pointer(pointer(response)) + + encoding = 'utf-8' + password = b'blank' + msg_list = [] + + libc = cdll.LoadLibrary(None) + + rv = my_conv(n_messages, + pp_messages, + pp_response, + libc, + msg_list, + password, + encoding) + + assert None is pp_response.contents.contents.resp + assert 0 == pp_response.contents.contents.resp_retcode + assert PAM_SUCCESS == rv diff --git a/version.py b/version.py deleted file mode 100644 index 5b51f06..0000000 --- a/version.py +++ /dev/null @@ -1,3 +0,0 @@ -VERSION = '2.0.0rc1' -AUTHOR = 'David Ford ' -RELEASED = '2021 December 3'