diff --git a/.editorconfig b/.editorconfig index b458585c45b727516d9c38ac8adb526cc786b5ad..81fd7bbc0a20631035ebd836e6787fa4e76f9cb4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,6 +6,6 @@ end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true -[*.py] +[*.rs] indent_style = space indent_size = 4 diff --git a/.gitignore b/.gitignore index 2146c76c4519ab6ab4923d0ca2e98ce95f0afc91..0fcc468a8751390f65545ce6c4fbe1350db0428d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,139 +1,14 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ +# Generated by Cargo +# will have compiled files and executables +debug/ target/ -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock -# Cython debug symbols -cython_debug/ +# These are backup files generated by rustfmt +**/*.rs.bk # Editor files *~ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..0553594fce6015fffe3332355e9cb08b001ccb2f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "nss_pam_oidc" +version = "0.1.0" +authors = ["Dominik George <nik@naturalnet.de>"] +edition = "2018" +description = "NSS/PAM modules for OpenID Connect/OAuth2" +repository = "https://edugit.org/debian-edu-ng/nss-pam-oidc" +license = "Apache-2.0" +categories = ["authentication", "os", "os::linux-apis"] + +[lib] +name = "nss_pam_oidc" +crate-type = [ "cdylib" ] + +[dependencies] +pamsm = { version = "^0.4.2", features = ["libpam"] } +oauth2 = "^4.0.0" +config = "^0.11.0" + +[profile.release] +opt-level = 'z' +lto = true diff --git a/lib/security/nss_pam_oidc.py b/lib/security/nss_pam_oidc.py deleted file mode 100644 index c52dee6c9ef9a6f04abf8efee285a84c8f7d96e5..0000000000000000000000000000000000000000 --- a/lib/security/nss_pam_oidc.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright 2021 Dominik George <nik@naturalnet.de> -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from nss_pam_oidc.pam import ( - pam_sm_authenticate, - pam_sm_setcred, - pam_sm_acct_mgmt, - pam_sm_open_session, - pam_sm_close_session, - pam_sm_chauthtok, -) diff --git a/nss_pam_oidc/__init__.py b/nss_pam_oidc/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/nss_pam_oidc/config.py b/nss_pam_oidc/config.py deleted file mode 100644 index 62f12b198986111d608f2a1e622be3ada34e931c..0000000000000000000000000000000000000000 --- a/nss_pam_oidc/config.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2021 Dominik George <nik@naturalnet.de> -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os - -import toml - -DEFAULT_CONFIG_FILE = "/etc/nss_pam_oidc.toml" -DEFAULT_CONFIG = { - "pam": { - "flow": "password", - }, - "nss": {}, -} - - -def load_config(config_file: str) -> dict[str, dict[str, str]]: - """Load full configuration, amended wit hdefault config.""" - config = {} - - # First, copy default configuration - for name, conf in DEFAULT_CONFIG.items(): - config[name] = conf.copy() - - # Second, load configuration from file - if os.path.exists(config_file): - config_toml = toml.load(config_file) - for name, conf in config.items(): - conf.update(config_toml.get(name, {})) - - return config - - -def get_config(section: str, **kwargs) -> dict[str, str]: - """Get configuration for a section, layered from defaults, config file, and args.""" - config_file = kwargs.pop("config", DEFAULT_CONFIG_FILE) - config = load_config(config_file) - - # Lastly, override with passed arguments - config[section].update(kwargs) - - return config[section] - - -def filter_config(config: dict[str, str], keys: list[str]) -> dict[str, str]: - """Return a copy of the config dictionary with only the requested keys.""" - return {k: v for k, v in config.items() if k in keys} diff --git a/nss_pam_oidc/pam.py b/nss_pam_oidc/pam.py deleted file mode 100644 index 4353e6eb406080081d6b4bc03538b8456836fa50..0000000000000000000000000000000000000000 --- a/nss_pam_oidc/pam.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright 2021 Dominik George <nik@naturalnet.de> -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from oauthlib.oauth2 import InvalidGrantError, LegacyApplicationClient -from requests.exceptions import RequestException -from requests_oauthlib import OAuth2Session - -from .config import filter_config, get_config - - -def _split_argv(argv: list[str]) -> dict[str, str]: - args = {} - for arg in argv: - if "=" in arg: - name, val = arg.split("=", 1) - else: - name, val = arg, True - args[name] = val - return args - - -def _do_legacy_auth(username: str, password: str, config: dict[str, str]): - client_config = filter_config(config, ["client_id"]) - session_config = filter_config(config, ["client_id"]) - fetch_config = filter_config(config, ["client_id", "client_secret", "token_url"]) - - client = LegacyApplicationClient(**client_config) - session = OAuth2Session(client=client, **session_config) - - token = session.fetch_token(username=username, password=password, **fetch_config) - return token - - -def pam_sm_authenticate(pamh, flags, argv): - args = _split_argv(argv) - config = get_config("pam", args) - - if config.pop("flow") == "password": - try: - user = pamh.get_user(None) - password = pamh.authtok - except pamh.exception as e: - return e.pam_result - if user is None or password is None: - return pamh.PAM_CRED_INSUFFICIENT - - try: - token = _do_legacy_auth(username, password) - except InvalidGrantError: - return pamh.PAM_AUTH_ERROR - except RequestException: - return pamh.PAM_AUTHINFO_UNAVAIL - except: - return pamh.PAM_SERVICE_ERR - - if "access_token" in token: - return pamh.PAM_SUCCESS - - return pamh.PAM_AUTH_ERR - - -def pam_sm_setcred(pamh, flags, argv): - return pamh.PAM_SUCCESS - - -def pam_sm_acct_mgmt(pamh, flags, argv): - return pamh.PAM_SUCCESS - - -def pam_sm_open_session(pamh, flags, argv): - return pamh.PAM_SUCCESS - - -def pam_sm_close_session(pamh, flags, argv): - return pamh.PAM_SUCCESS - - -def pam_sm_chauthtok(pamh, flags, argv): - return pamh.PAM_SUCCESS diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index bc39f6bc252c57cae23dfb41a68b448067f6bc1c..0000000000000000000000000000000000000000 --- a/poetry.lock +++ /dev/null @@ -1,130 +0,0 @@ -[[package]] -name = "certifi" -version = "2020.12.5" -description = "Python package for providing Mozilla's CA Bundle." -category = "main" -optional = false -python-versions = "*" - -[[package]] -name = "chardet" -version = "4.0.0" -description = "Universal encoding detector for Python 2 and 3" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[[package]] -name = "idna" -version = "2.10" -description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]] -name = "oauthlib" -version = "3.1.0" -description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.extras] -rsa = ["cryptography"] -signals = ["blinker"] -signedtoken = ["cryptography", "pyjwt (>=1.0.0)"] - -[[package]] -name = "requests" -version = "2.25.1" -description = "Python HTTP for Humans." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.dependencies] -certifi = ">=2017.4.17" -chardet = ">=3.0.2,<5" -idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.27" - -[package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] - -[[package]] -name = "requests-oauthlib" -version = "1.3.0" -description = "OAuthlib authentication support for Requests." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.dependencies] -oauthlib = ">=3.0.0" -requests = ">=2.0.0" - -[package.extras] -rsa = ["oauthlib[signedtoken] (>=3.0.0)"] - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -category = "main" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" - -[[package]] -name = "urllib3" -version = "1.26.4" -description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" - -[package.extras] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] -brotli = ["brotlipy (>=0.6.0)"] - -[metadata] -lock-version = "1.1" -python-versions = "^3.9" -content-hash = "f70d776e9a6a2676f9c1816a08a35616c61a5a9af75631200da033c3afd4cb4e" - -[metadata.files] -certifi = [ - {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, - {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, -] -chardet = [ - {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, - {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, -] -idna = [ - {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, - {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, -] -oauthlib = [ - {file = "oauthlib-3.1.0-py2.py3-none-any.whl", hash = "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"}, - {file = "oauthlib-3.1.0.tar.gz", hash = "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889"}, -] -requests = [ - {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, - {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, -] -requests-oauthlib = [ - {file = "requests-oauthlib-1.3.0.tar.gz", hash = "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"}, - {file = "requests_oauthlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d"}, - {file = "requests_oauthlib-1.3.0-py3.7.egg", hash = "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc"}, -] -toml = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] -urllib3 = [ - {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, - {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, -] diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index b97730d6b35f11d46f0572bea95f3a53e9c2910e..0000000000000000000000000000000000000000 --- a/pyproject.toml +++ /dev/null @@ -1,24 +0,0 @@ -[tool.poetry] -name = "nss-pam-oidc" -version = "0.1.0" -description = "NSS/PAM modules for OpenID Connect/OAuth2" -authors = ["Dominik George <nik@naturalnet.de>"] -license = "Apache-2.0" -readme = "README.rst" -repository = "https://edugit.org/debian-edu-ng/nss-pam-oidc" -classifiers = [ - "Environment :: Console", - "Intended Audience :: System Administrators", -] - -[tool.poetry.dependencies] -python = "^3.9" -toml = "^0.10.2" -oauthlib = "^3.1.0" -requests-oauthlib = "^1.3.0" - -[tool.poetry.dev-dependencies] - -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000000000000000000000000000000000000..66cf6b2f07f93d91061a797ad3c3b49e14b1a6a0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,57 @@ +/* Copyright 2021 Dominik George <nik@naturalnet.de> + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +extern crate config; + +fn load_config(config_file: String) -> config::Config { + let mut conf = config::Config::default(); + + conf.set("pam.flow", "password"); + + conf + .merge(config::File::with_name(&config_file)).unwrap() + .merge(config::Environment::with_prefix("NSS_PAM_OIDC")).unwrap(); + + return conf; +} + +pub fn get_config(conf_args: config::Config) -> config::Config { + let config_file: String; + let config_file_passed = conf_args.get_str("config"); + if config_file_passed.is_ok() { + config_file = config_file_passed.unwrap().to_string(); + } else { + config_file = "/etc/nss_pam_oidc".to_string(); + } + + let mut conf = load_config(config_file); + conf.merge(conf_args).unwrap(); + + return conf; +} + +pub fn argv_to_config(argv: Vec<String>) -> config::Config { + let mut conf = config::Config::default(); + + for arg in &argv { + if arg.contains("=") { + let split: Vec<&str> = arg.split("=").collect(); + conf.set(split[0], split[1]); + } else { + conf.set(arg, true); + } + } + return conf; +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..1762c0e8764bb393213b3768064678e793b766d8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +mod config; +mod pam; diff --git a/src/pam.rs b/src/pam.rs new file mode 100644 index 0000000000000000000000000000000000000000..d2e9aab38f07d00d2dcc15aa1793a8ca365495fe --- /dev/null +++ b/src/pam.rs @@ -0,0 +1,91 @@ +use crate::config::{ + argv_to_config, + get_config +}; +use config::Config; + +use oauth2::{ + AuthUrl, + ClientId, + ClientSecret, + ResourceOwnerPassword, + ResourceOwnerUsername, + Scope, + TokenUrl +}; +use oauth2::basic::{ + BasicClient, + BasicTokenResponse +}; +use oauth2::reqwest::http_client; + +extern crate pamsm; +use pamsm::{PamServiceModule, Pam, PamFlag, PamError, PamLibExt}; + +fn get_or_pam_error(config: &Config, key: &str) -> Result<String, PamError> { + match config.get_str(key) { + Ok(v) => Ok(v), + _ => Err(PamError::SERVICE_ERR), + } +} + +fn do_legacy_auth(username: String, password: String, config: Config) -> Result<BasicTokenResponse, PamError> { + let client_id = ClientId::new(get_or_pam_error(&config, "pam.client_id")?); + let client_secret = ClientSecret::new(get_or_pam_error(&config, "pam.client_secret")?); + let auth_url = match AuthUrl::new(get_or_pam_error(&config, "pam.auth_url")?) { + Ok(u) => u, + _ => return Err(PamError::SERVICE_ERR), + }; + let token_url = match TokenUrl::new(get_or_pam_error(&config, "pam.token_url")?){ + Ok(u) => u, + _ => return Err(PamError::SERVICE_ERR), + }; + let scope = get_or_pam_error(&config, "pam.scope")?; + + let client = BasicClient::new(client_id, Some(client_secret), auth_url, Some(token_url)); + match client + .exchange_password( + &ResourceOwnerUsername::new(username), + &ResourceOwnerPassword::new(password) + ) + .add_scope(Scope::new(scope.to_string())) + .request(http_client) { + Ok(t) => Ok(t), + _ => Err(PamError::AUTHINFO_UNAVAIL), + } +} + +struct PamOidc; + +impl PamServiceModule for PamOidc { + fn authenticate(pamh: Pam, _: PamFlag, argv: Vec<String>) -> PamError { + let conf_args = argv_to_config(argv); + let conf = get_config(conf_args); + + if conf.get_str("pam.flow").unwrap() == "password" { + let username = match pamh.get_user(None) { + Ok(Some(u)) => match u.to_str() { + Ok(u) => u, + _ => return PamError::CRED_INSUFFICIENT, + }, + Ok(None) => return PamError::CRED_INSUFFICIENT, + Err(e) => return e, + }; + let password = match pamh.get_authtok(None) { + Ok(Some(p)) => match p.to_str() { + Ok(p) => p, + _ => return PamError::CRED_INSUFFICIENT, + }, + Ok(None) => return PamError::CRED_INSUFFICIENT, + Err(e) => return e, + }; + + match do_legacy_auth(username.to_string(), password.to_string(), conf) { + Ok(_) => return PamError::SUCCESS, + Err(e) => return e, + }; + } + + return PamError::SERVICE_ERR; + } +}