Skip to content

Commit 66e11d9

Browse files
irfansharIrfan SharifIrfan Sharifajshedivy
authored
Kerberos support (#84)
* Allow support for kerberos authentication Signed-off-by: Irfan Sharif <[email protected]> * Update tests to use either kerb auth or username/password Signed-off-by: Irfan Sharif <[email protected]> * Update pytests.ini.sample with more fields Signed-off-by: Irfan Sharif <[email protected]> * update requirements Signed-off-by: Irfan Sharif <[email protected]> * Platform agnostic kerberos token generation Signed-off-by: Irfan Sharif <[email protected]> * working windows client * Remove unnecessary imports * Clean up system dependant import logic * Abstract token setting * Exceptions being thrown * fix token expiry logic Signed-off-by: Irfan Sharif <[email protected]> * Throw appropriate errors during GSS errors Signed-off-by: Irfan Sharif <[email protected]> * Update error message if no valid TGT in ccache Signed-off-by: Irfan Sharif <[email protected]> * allow import errors to propogate instead of ignoring them Signed-off-by: Irfan Sharif <[email protected]> * Simplify KerberosTokenProvider usage in DaemonServer Signed-off-by: Irfan Sharif <[email protected]> * Simplify constructor for Windows Signed-off-by: Irfan Sharif <[email protected]> * Simplify token generation to always refresh Signed-off-by: Irfan Sharif <[email protected]> * Remove unnecesary import check Signed-off-by: Irfan Sharif <[email protected]> * Make platform constant Signed-off-by: Irfan Sharif <[email protected]> * remove unnecesarry imports Signed-off-by: Irfan Sharif <[email protected]> * Update requirements-dev.txt Signed-off-by: Irfan Sharif <[email protected]> * Update requirements-dev for Windows kerberos support Signed-off-by: Irfan Sharif <[email protected]> * Update pywin32 in requirements-dev to only install on Windows Signed-off-by: Irfan Sharif <[email protected]> * Updated dependencies in pyproject.toml Signed-off-by: Irfan Sharif <[email protected]> * Formatting Signed-off-by: Irfan Sharif <[email protected]> * Updated docs with Kerberos usage Signed-off-by: Irfan Sharif <[email protected]> * Update CHANGELOG Signed-off-by: Irfan Sharif <[email protected]> * add uv lock * update actions/upload-artifact@v3 -> v4 * update GitHub Actions to use latest versions of checkout, cache, setup-python, and download-artifact * update actions/setup-python and actions/cache to latest versions * add installation of system dependencies for Python virtual environment setup --------- Signed-off-by: Irfan Sharif <[email protected]> Signed-off-by: Irfan Sharif <[email protected]> Co-authored-by: Irfan Sharif <[email protected]> Co-authored-by: Irfan Sharif <[email protected]> Co-authored-by: Adam Shedivy <[email protected]>
1 parent 48d6b2c commit 66e11d9

23 files changed

+2447
-128
lines changed

.github/actions/setup-venv/action.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ inputs:
1111
runs:
1212
using: composite
1313
steps:
14+
- name: Install system dependencies
15+
shell: bash
16+
run: |
17+
sudo apt-get update
18+
sudo apt-get install -y libkrb5-dev
19+
1420
- name: Setup Python
15-
uses: actions/setup-python@v4
21+
uses: actions/setup-python@v5
1622
with:
1723
python-version: ${{ inputs.python-version }}
1824

@@ -26,7 +32,7 @@ runs:
2632
# Get the exact Python version to use in the cache key.
2733
echo "PYTHON_VERSION=$(python --version)" >> $GITHUB_ENV
2834
29-
- uses: actions/cache@v2
35+
- uses: actions/cache@v4
3036
id: virtualenv-cache
3137
with:
3238
path: .venv

.github/workflows/main.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ jobs:
6868
run: cd docs && make html
6969

7070
steps:
71-
- uses: actions/checkout@v3
71+
- uses: actions/checkout@v4
7272

7373
- name: Setup Python environment
7474
uses: ./.github/actions/setup-venv
@@ -91,7 +91,7 @@ jobs:
9191

9292
- name: Restore mypy cache
9393
if: matrix.task.name == 'Type check'
94-
uses: actions/cache@v3
94+
uses: actions/cache@v4
9595
with:
9696
path: .mypy_cache
9797
key: mypy-${{ env.CACHE_PREFIX }}-${{ runner.os }}-${{ matrix.python }}-${{ hashFiles('*requirements.txt') }}-${{ github.ref }}-${{ github.sha }}
@@ -118,7 +118,7 @@ jobs:
118118
119119
- name: Upload package distribution files
120120
if: matrix.task.name == 'Build'
121-
uses: actions/upload-artifact@v3
121+
uses: actions/upload-artifact@v4
122122
with:
123123
name: package
124124
path: dist
@@ -135,12 +135,12 @@ jobs:
135135
needs: [checks]
136136
if: startsWith(github.ref, 'refs/tags/')
137137
steps:
138-
- uses: actions/checkout@v3
138+
- uses: actions/checkout@v4
139139
with:
140140
fetch-depth: 0
141141

142142
- name: Setup Python
143-
uses: actions/setup-python@v4
143+
uses: actions/setup-python@v5
144144
with:
145145
python-version: "3.10"
146146

@@ -155,7 +155,7 @@ jobs:
155155
echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
156156
157157
- name: Download package distribution files
158-
uses: actions/download-artifact@v3
158+
uses: actions/download-artifact@v4
159159
with:
160160
name: package
161161
path: dist
@@ -169,7 +169,7 @@ jobs:
169169
twine upload -u '${{ secrets.PYPI_USERNAME }}' -p '${{ secrets.PYPI_PASSWORD }}' dist/*
170170
171171
- name: Publish GitHub release
172-
uses: softprops/action-gh-release@v1
172+
uses: softprops/action-gh-release@v2
173173
env:
174174
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN}}
175175
with:

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## Unreleased
99
- add TLS support
1010
- enable server certificate verification by default
11+
- enable Kerberos authentication
1112

1213
## [v0.2.0](https://github.com/Mapepire-IBMi/mapepire-python/releases/tag/v0.2.0) - 2024-11-26
1314
- replace `websocket-client` with `websockets`

README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
- [Quick Start](#quick-start)
2424
- [Other Connection options](#other-connection-options)
2525
- [1. Using the `DaemonServer` object](#1-using-the-daemonserver-object)
26+
- [1.1 Authenticating with Kerberos](#11-authenticating-with-kerberos)
27+
- [1.1.1 Windows](#111-windows)
28+
- [1.1.2 Other Platforms](#112-other-platforms)
2629
- [2. Passing the connection details as a dictionary](#2-passing-the-connection-details-as-a-dictionary)
2730
- [3. Using a config file (`.ini`) to store the connection details](#3-using-a-config-file-ini-to-store-the-connection-details)
2831
- [TLS Configuration](#tls-configuration)
@@ -155,6 +158,63 @@ creds = DaemonServer(
155158
job = SQLJob(creds)
156159
```
157160

161+
### 1.1 Authenticating with Kerberos
162+
If your IBM i is configured to support Kerberos authentication, you can authenticate using Kerberos instead of passing a plain-text password to the `DaemonServer` Object.
163+
164+
#### 1.1.1 Windows
165+
166+
If your Windows machine is part of a Kerberos realm and supports SSPI authentication, you can authenticate by creating the `DaemonServer` as shown below:
167+
```python
168+
from mapepire_python.data_types import DaemonServer
169+
from mapepire_python.authentication.kerberosTokenProvider import KerberosTokenProvider
170+
171+
creds = DaemonServer(
172+
host="SERVER",
173+
password=KerberosTokenProvider(host="SERVER"),
174+
user="USER",
175+
port="PORT",
176+
)
177+
178+
job = SQLJob(creds)
179+
```
180+
181+
#### 1.1.2 Other Platforms
182+
183+
For non-Windows platforms, Kerberos authentication requires a valid Ticket Granting Ticket (TGT) in your credential cache.
184+
185+
Required Parameters:
186+
187+
1. `host`: The IBM i host you are connecting to
188+
2. `realm`: Your Kerberos realm
189+
3. `realm_user`: Your Kerberos username
190+
4. `krb5_path`: Path to your `krb5.conf` configuration file
191+
192+
Optional Parameters:
193+
194+
1. `ticket_cache`: Path to your ticket cache (if not default)
195+
2. `krb5_mech`: The Kerberos 5 mechanism to use (if not default)
196+
197+
```python
198+
from mapepire_python.data_types import DaemonServer
199+
from mapepire_python.authentication.kerberosTokenProvider import KerberosTokenProvider
200+
201+
token_provider = KerberosTokenProvider(
202+
realm="REALM",
203+
realm_user="REALM_USER",
204+
host="SERVER",
205+
krb5_path="KRB5_PATH"
206+
)
207+
208+
creds = DaemonServer(
209+
host="SERVER",
210+
password=token_provider,
211+
user="USER",
212+
port="PORT",
213+
)
214+
215+
job = SQLJob(creds)
216+
```
217+
158218
## 2. Passing the connection details as a dictionary
159219

160220
You can also use a dictionary to configure the connection details:
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import base64
2+
import os
3+
import platform
4+
from typing import Final, Optional
5+
6+
sspi = None
7+
gssapi = None
8+
PLATFORM = platform.system()
9+
TOKEN_PREFIX: Final = "_KERBEROSAUTH_"
10+
11+
12+
# For Windows SSPI token generation:
13+
if PLATFORM == "Windows":
14+
import sspi
15+
else:
16+
import gssapi
17+
18+
19+
class KerberosTokenProvider:
20+
def __init__(
21+
self,
22+
host: str,
23+
realm: Optional[str] = None,
24+
realm_user: Optional[str] = None,
25+
krb5_path: Optional[str] = None,
26+
ticket_cache: Optional[str] = None,
27+
krb5_mech: Optional[str] = None,
28+
):
29+
self.host = host
30+
self.realm = realm
31+
self.realm_user = realm_user
32+
self.krb5_path = krb5_path
33+
self.ticket_cache = ticket_cache
34+
self.krb5_mech = krb5_mech
35+
36+
if PLATFORM != "Windows":
37+
missing = []
38+
if not self.realm:
39+
missing.append("realm")
40+
if not self.realm_user:
41+
missing.append("realm_user")
42+
if not self.krb5_path:
43+
missing.append("krb5_path")
44+
if missing:
45+
raise ValueError(f"Missing required parameters: {', '.join(missing)}")
46+
47+
def get_token(self) -> str:
48+
return self._refresh_token()
49+
50+
def _refresh_token(self) -> str:
51+
if PLATFORM == "Windows":
52+
return self._refresh_token_windows()
53+
else:
54+
return self._refresh_token_unix()
55+
56+
def _format_token(self, token: bytes) -> str:
57+
token_b64 = base64.b64encode(token).decode("utf-8")
58+
return TOKEN_PREFIX + token_b64
59+
60+
def _refresh_token_windows(self) -> str:
61+
target = f"krbsvr400/{self.host}"
62+
client = sspi.ClientAuth("Kerberos", targetspn=target)
63+
64+
err, out_buffer = client.authorize(None)
65+
if err != 0:
66+
raise RuntimeError(f"Windows SSPI error when attempting Kerberos login: {hex(err)}")
67+
68+
token = out_buffer[0].Buffer
69+
return self._format_token(token)
70+
71+
def _refresh_token_unix(self) -> str:
72+
os.environ["KRB5_CONFIG"] = self.krb5_path
73+
if self.ticket_cache:
74+
os.environ["KRB5CCNAME"] = self.ticket_cache
75+
76+
mech = (
77+
gssapi.OID.from_int_seq("1.2.840.113554.1.2.2")
78+
if self.krb5_mech is None
79+
else gssapi.OID.from_int_seq(self.krb5_mech)
80+
)
81+
82+
try:
83+
user_name = gssapi.Name(
84+
f"{self.realm_user}@{self.realm}", name_type=gssapi.NameType.user
85+
)
86+
cred = gssapi.Credentials(name=user_name, usage="initiate", mechs=[mech])
87+
server_name = gssapi.Name(
88+
f"krbsvr400@{self.host}", name_type=gssapi.NameType.hostbased_service
89+
)
90+
ctx = gssapi.SecurityContext(name=server_name, mech=mech, creds=cred, usage="initiate")
91+
92+
token = ctx.step(b"")
93+
if token is None:
94+
raise RuntimeError(
95+
"Failed to generate Kerberos token. No token returned from GSSAPI context."
96+
)
97+
except gssapi.exceptions.GSSError as e:
98+
if "No credentials were supplied" in str(e) or "Unavailable" in str(e):
99+
raise RuntimeError("No valid TGT found in credential cache.")
100+
raise RuntimeError(
101+
f"Kerberos token generation error when attempting Kerberos login: {str(e)}"
102+
)
103+
104+
return self._format_token(token)

mapepire_python/core/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def wrapped(self, *args, **kwargs):
2626

2727

2828
def ignore_transaction_error(
29-
method: Callable[..., ReturnType]
29+
method: Callable[..., ReturnType],
3030
) -> Callable[..., Optional[ReturnType]]:
3131
"""
3232
Ignore transaction errors, returning `None` instead. Useful for

mapepire_python/data_types.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from dataclasses_json import dataclass_json
66

7+
from mapepire_python.authentication.kerberosTokenProvider import KerberosTokenProvider
8+
79

810
def dict_to_dataclass(data: Dict[str, Any], dataclass_type: Any) -> Any:
911
field_names = {f.name for f in fields(dataclass_type)}
@@ -44,11 +46,16 @@ class ServerTraceDest(Enum):
4446
class DaemonServer:
4547
host: str
4648
user: str
47-
password: str
49+
password: Union[str, KerberosTokenProvider]
4850
port: Optional[Union[str, int]]
4951
ignoreUnauthorized: Optional[bool] = False
5052
ca: Optional[Union[str, bytes]] = None
5153

54+
def get_password(self) -> str:
55+
if isinstance(self.password, KerberosTokenProvider):
56+
return self.password.get_token()
57+
return self.password
58+
5259

5360
@dataclass_json
5461
@dataclass

mapepire_python/websocket.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ def __init__(self, db2_server: DaemonServer) -> None:
1616
self.uri = f"wss://{db2_server.host}:{db2_server.port}/db/"
1717
self.headers = {
1818
"Authorization": "Basic "
19-
+ base64.b64encode(f"{db2_server.user}:{db2_server.password}".encode()).decode("ascii")
19+
+ base64.b64encode(f"{db2_server.user}:{db2_server.get_password()}".encode()).decode(
20+
"ascii"
21+
)
2022
}
2123
self.db2_server = db2_server
2224

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@ dev = [
5656
"sphinx-autobuild==2021.3.14",
5757
"sphinx-autodoc-typehints==1.23.3",
5858
"packaging",
59-
"pre-commit"
59+
"pre-commit",
60+
"python-dotenv",
61+
"pytest-env",
62+
"gssapi",
63+
'pywin32; sys_platform == "win32"'
6064
]
6165

6266
[tool.setuptools.packages.find]

requirements-dev.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ black
1010
ruff
1111
mypy
1212
websockets
13-
pyee
13+
pyee
14+
gssapi
15+
pywin32; sys_platform == "win32"

0 commit comments

Comments
 (0)