Skip to content

Commit 1e82db2

Browse files
authored
Ginter/krb5 (#24)
1 parent 70173bf commit 1e82db2

File tree

8 files changed

+204
-31
lines changed

8 files changed

+204
-31
lines changed

.github/workflows/tests.yml

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ jobs:
1010
python-version: ["3.7", "3.8", "3.9", "3.10"]
1111
steps:
1212
- uses: actions/checkout@v2
13+
- name: Set up Kerberos
14+
run: sudo apt-get install -y libkrb5-dev krb5-kdc krb5-admin-server
1315
- name: Set up Python ${{ matrix.python-version }}
1416
uses: actions/setup-python@v2
1517
with:
@@ -37,6 +39,8 @@ jobs:
3739
runs-on: ubuntu-latest
3840
steps:
3941
- uses: actions/checkout@v2
42+
- name: Set up Kerberos
43+
run: sudo apt-get install -y libkrb5-dev krb5-kdc krb5-admin-server
4044
- name: Set up Python
4145
uses: actions/setup-python@v2
4246
with:

.pylintrc

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
[MESSAGES CONTROL]
22

33
disable=
4+
import-outside-toplevel,
45
invalid-name,
56
missing-class-docstring,
67
missing-function-docstring,

README.md

+9-6
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ if __name__ == "__main__":
4646
asyncio.run(server.serve_forever())
4747
```
4848

49-
Using [sqlglot](https://github.com/tobymao/sqlglot), the abstract `Session` class handles queries to metadata, variables, etc. that many MySQL clients expect.
49+
Using [sqlglot](https://github.com/tobymao/sqlglot), the abstract `Session` class handles queries to metadata, variables, etc. that many MySQL clients expect.
5050

5151
To bypass this default behavior, you can implement the [`mysql_mimic.session.BaseSession`](mysql_mimic/session.py) interface.
5252

@@ -63,7 +63,10 @@ MySQL-mimic has built in support for several standard MySQL authentication plugi
6363
- This is typically used as the client plugin for a custom server plugin. As such, MySQL-mimic provides an abstract class, [`mysql_mimic.auth.AbstractClearPasswordAuthPlugin`](mysql_mimic/auth.py), which can be extended.
6464
- [example](examples/auth_clear_password.py)
6565
- [mysql_no_login](https://dev.mysql.com/doc/refman/8.0/en/no-login-pluggable-authentication.html)
66-
- The server prevents clients from directly authenticating as an account. See the documentation for relevant use cases.
66+
- The server prevents clients from directly authenticating as an account. See the documentation for relevant use cases.
67+
- [authentication_kerberos](https://dev.mysql.com/doc/mysql-security-excerpt/8.0/en/kerberos-pluggable-authentication.html)
68+
- Kerberos uses tickets together with symmetric-key cryptography, enabling authentication without sending passwords over the network. Kerberos authentication supports userless and passwordless scenarios.
69+
6770

6871
By default, a session naively accepts whatever username the client provides.
6972

@@ -73,15 +76,15 @@ Custom plugins can be created by extending [`mysql_mimic.auth.AuthPlugin`](mysql
7376

7477
## Development
7578

76-
You can install dependencies with `make deps`.
79+
You can install dependencies with `make deps`.
7780

78-
You can format your code with `make format`.
81+
You can format your code with `make format`.
7982

80-
You can lint with `make lint`.
83+
You can lint with `make lint`.
8184

8285
You can check type annotations with `make types`.
8386

84-
You can run tests with `make test`. This will build a coverage report in `./htmlcov/index.html`.
87+
You can run tests with `make test`. This will build a coverage report in `./htmlcov/index.html`.
8588

8689
You can run all the checks with `make check`.
8790

integration/mysql-connector-j/Makefile

+3
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@
22

33
test:
44
mvn test
5+
6+
test-debug:
7+
mvn -X test

integration/mysql-connector-j/src/test/java/com/mysql_mimic/integration/IntegrationTest.java

+48
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import java.sql.Connection;
77
import java.sql.DriverManager;
88
import java.sql.Statement;
9+
import java.sql.SQLException;
910
import java.sql.ResultSet;
1011
import java.util.ArrayList;
1112
import java.util.List;
@@ -36,4 +37,51 @@ public void test() throws Exception {
3637

3738
assertEquals(expected, result);
3839
}
40+
41+
@Test
42+
public void testKrb5() throws Exception {
43+
// System.setProperty("sun.security.krb5.debug", "True");
44+
// System.setProperty("sun.security.jgss.debug", "True");
45+
System.setProperty("java.security.krb5.conf", System.getenv("KRB5_CONFIG"));
46+
System.setProperty("java.security.auth.login.config", System.getenv("JAAS_CONFIG"));
47+
48+
Class.forName("com.mysql.cj.jdbc.Driver").newInstance();
49+
50+
String port = System.getenv("PORT");
51+
String url = String.format("jdbc:mysql://127.0.0.1:%s/?defaultAuthenticationPlugin=authentication_kerberos_client", port);
52+
53+
Connection conn = DriverManager.getConnection(url);
54+
55+
Statement stmt = conn.createStatement();
56+
ResultSet rs = stmt.executeQuery("SELECT a FROM x ORDER BY a");
57+
58+
List<Integer> result = new ArrayList<>();
59+
while (rs.next()) {
60+
result.add(rs.getInt("a"));
61+
}
62+
63+
List<Integer> expected = new ArrayList<>();
64+
expected.add(1);
65+
expected.add(2);
66+
expected.add(3);
67+
68+
assertEquals(expected, result);
69+
}
70+
71+
@Test(expected = SQLException.class)
72+
public void testKrb5IncorrectUser() throws Exception {
73+
System.setProperty("java.security.krb5.conf", System.getenv("KRB5_CONFIG"));
74+
System.setProperty("java.security.auth.login.config", System.getenv("JAAS_CONFIG"));
75+
76+
Class.forName("com.mysql.cj.jdbc.Driver").newInstance();
77+
78+
String user = "incorrect_user";
79+
String port = System.getenv("PORT");
80+
String url = String.format("jdbc:mysql://%[email protected]:%s/?defaultAuthenticationPlugin=authentication_kerberos_client", user, port);
81+
82+
Connection conn = DriverManager.getConnection(url);
83+
84+
Statement stmt = conn.createStatement();
85+
stmt.executeQuery("SELECT a FROM x ORDER BY a");
86+
}
3987
}

integration/run.py

+89-25
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import asyncio
44
import os
55
import sys
6+
import tempfile
67
import time
78

9+
import k5test
810
from sqlglot.executor import execute
911

1012
from mysql_mimic import (
@@ -14,6 +16,7 @@
1416
User,
1517
Session,
1618
)
19+
from mysql_mimic.auth import KerberosAuthPlugin
1720

1821
logger = logging.getLogger(__name__)
1922

@@ -46,22 +49,36 @@ async def schema(self):
4649

4750

4851
class CustomIdentityProvider(IdentityProvider):
49-
def __init__(self):
50-
self.passwords = {"user": "password"}
52+
def __init__(self, krb5_service, krb5_realm):
53+
self.users = {
54+
"user": {"auth_plugin": "mysql_native_password", "password": "password"},
55+
"krb5_user": {"auth_plugin": "authentication_kerberos"},
56+
}
57+
self.krb5_service = krb5_service
58+
self.krb5_realm = krb5_realm
5159

5260
def get_plugins(self):
53-
return [NativePasswordAuthPlugin()]
61+
return [
62+
NativePasswordAuthPlugin(),
63+
KerberosAuthPlugin(service=self.krb5_service, realm=self.krb5_realm),
64+
]
5465

5566
async def get_user(self, username):
56-
password = self.passwords.get(username)
57-
if password is not None:
58-
return User(
59-
name=username,
60-
auth_string=NativePasswordAuthPlugin.create_auth_string(password)
61-
if password
62-
else None,
63-
auth_plugin=NativePasswordAuthPlugin.name,
64-
)
67+
user = self.users.get(username)
68+
if user is not None:
69+
auth_plugin = user["auth_plugin"]
70+
71+
if auth_plugin == "mysql_native_password":
72+
password = user.get("password")
73+
return User(
74+
name=username,
75+
auth_string=NativePasswordAuthPlugin.create_auth_string(password)
76+
if password
77+
else None,
78+
auth_plugin=NativePasswordAuthPlugin.name,
79+
)
80+
elif auth_plugin == "authentication_kerberos":
81+
return User(name=username, auth_plugin=KerberosAuthPlugin.name)
6582
return None
6683

6784

@@ -77,26 +94,73 @@ async def wait_for_port(port, host="localhost", timeout=5.0):
7794
raise TimeoutError()
7895

7996

97+
def setup_krb5(krb5_user):
98+
realm = k5test.K5Realm()
99+
krb5_user_princ = f"{krb5_user}@{realm.realm}"
100+
realm.addprinc(krb5_user_princ, realm.password(krb5_user))
101+
realm.kinit(krb5_user_princ, realm.password(krb5_user))
102+
return realm
103+
104+
105+
def write_jaas_conf(realm, debug=False):
106+
conf = f"""
107+
MySQLConnectorJ {{
108+
com.sun.security.auth.module.Krb5LoginModule
109+
required
110+
debug={str(debug).lower()}
111+
useTicketCache=true
112+
ticketCache="{realm.env["KRB5CCNAME"]}";
113+
}};
114+
"""
115+
path = tempfile.mktemp()
116+
with open(path, "w") as f:
117+
f.write(conf)
118+
119+
return path
120+
121+
80122
async def main():
81123
parser = argparse.ArgumentParser()
82124
parser.add_argument("test_dir")
83125
parser.add_argument("-p", "--port", type=int, default=3308)
84126
args = parser.parse_args()
85127

86128
logging.basicConfig(level=logging.DEBUG)
87-
identity_provider = CustomIdentityProvider()
88-
server = MysqlServer(identity_provider=identity_provider, session_factory=MySession)
89-
await server.start_server(port=args.port)
90-
await wait_for_port(port=args.port)
91-
process = await asyncio.create_subprocess_shell(
92-
"make test",
93-
env={**os.environ, "PORT": str(args.port)},
94-
cwd=args.test_dir,
95-
)
96-
return_code = await process.wait()
97-
server.close()
98-
await server.wait_closed()
99-
return return_code
129+
realm = None
130+
jaas_conf = None
131+
132+
try:
133+
realm = setup_krb5(krb5_user="krb5_user")
134+
os.environ.update(realm.env)
135+
136+
jaas_conf = write_jaas_conf(realm)
137+
os.environ["JAAS_CONFIG"] = jaas_conf
138+
139+
krb5_service = realm.host_princ[: realm.host_princ.index("@")]
140+
identity_provider = CustomIdentityProvider(
141+
krb5_service=krb5_service, krb5_realm=realm.realm
142+
)
143+
server = MysqlServer(
144+
identity_provider=identity_provider, session_factory=MySession
145+
)
146+
147+
await server.start_server(port=args.port)
148+
await wait_for_port(port=args.port)
149+
process = await asyncio.create_subprocess_shell(
150+
"make test",
151+
env={**os.environ, "PORT": str(args.port)},
152+
cwd=args.test_dir,
153+
)
154+
return_code = await process.wait()
155+
156+
server.close()
157+
await server.wait_closed()
158+
return return_code
159+
finally:
160+
if realm:
161+
realm.stop()
162+
if jaas_conf:
163+
os.remove(jaas_conf)
100164

101165

102166
if __name__ == "__main__":

mysql_mimic/auth.py

+47
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,53 @@ def create_auth_string(cls, password: str) -> str:
175175
return sha1(sha1(password.encode("utf-8")).digest()).hexdigest()
176176

177177

178+
class KerberosAuthPlugin(AuthPlugin):
179+
"""
180+
This plugin implements the Generic Security Service Application Program Interface (GSS-API) by way of the Kerberos
181+
mechanism as described in RFC1964(https://www.rfc-editor.org/rfc/rfc1964.html).
182+
"""
183+
184+
name = "authentication_kerberos"
185+
client_plugin_name = "authentication_kerberos_client"
186+
187+
def __init__(self, service: str, realm: str) -> None:
188+
self.service = service
189+
self.realm = realm
190+
191+
async def auth(self, auth_info: Optional[AuthInfo] = None) -> AuthState:
192+
import gssapi
193+
194+
# Fast authentication not supported
195+
if not auth_info:
196+
yield b""
197+
198+
auth_info = (
199+
yield len(self.service).to_bytes(2, "little")
200+
+ self.service.encode("utf-8")
201+
+ len(self.realm).to_bytes(2, "little")
202+
+ self.realm.encode("utf-8")
203+
)
204+
205+
server_creds = gssapi.Credentials(
206+
usage="accept", name=gssapi.Name(f"{self.service}@{self.realm}")
207+
)
208+
server_ctx = gssapi.SecurityContext(usage="accept", creds=server_creds)
209+
210+
client_name = gssapi.Name(f"{auth_info.username}@{self.realm}").canonicalize(
211+
gssapi.MechType.kerberos
212+
)
213+
client_token = auth_info.data
214+
215+
server_token = server_ctx.step(client_token)
216+
217+
if server_ctx.initiator_name == client_name:
218+
if gssapi.RequirementFlag.mutual_authentication in server_ctx.actual_flags:
219+
auth_info = yield server_token
220+
yield Success(auth_info.username)
221+
else:
222+
yield Forbidden()
223+
224+
178225
class NoLoginAuthPlugin(AuthPlugin):
179226
"""
180227
Standard plugin that prevents all clients from direct login.

setup.py

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
"mysql-connector-python",
2424
"black",
2525
"coverage",
26+
"gssapi",
27+
"k5test",
2628
"pylint",
2729
"pytest",
2830
"pytest-asyncio",
@@ -31,6 +33,7 @@
3133
"twine",
3234
"wheel",
3335
],
36+
"krb5": ["gssapi"],
3437
},
3538
classifiers=[
3639
"Development Status :: 3 - Alpha",

0 commit comments

Comments
 (0)