RFL.authentication 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.1
2
+ Name: RFL.authentication
3
+ Version: 1.0.0
4
+ Summary: Rackslab Foundation Library: authentication package
5
+ Author-email: Rémi Palancher <remi@rackslab.io>
6
+ License: GPLv3+
7
+ Project-URL: Homepage, https://github.com/rackslab/RFL
8
+ Project-URL: Bug Tracker, https://github.com/rackslab/RFL/issues
9
+ Keywords: utility,basic
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: System :: Systems Administration
16
+ Requires-Python: >=3.6
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: RFL.core
19
+ Requires-Dist: PyJWT
20
+ Requires-Dist: python-ldap
21
+
22
+ # RFL: authentication package
23
+
24
+ Generate and validate JWT tokens.
@@ -0,0 +1,3 @@
1
+ # RFL: authentication package
2
+
3
+ Generate and validate JWT tokens.
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.1
2
+ Name: RFL.authentication
3
+ Version: 1.0.0
4
+ Summary: Rackslab Foundation Library: authentication package
5
+ Author-email: Rémi Palancher <remi@rackslab.io>
6
+ License: GPLv3+
7
+ Project-URL: Homepage, https://github.com/rackslab/RFL
8
+ Project-URL: Bug Tracker, https://github.com/rackslab/RFL/issues
9
+ Keywords: utility,basic
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: System :: Systems Administration
16
+ Requires-Python: >=3.6
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: RFL.core
19
+ Requires-Dist: PyJWT
20
+ Requires-Dist: python-ldap
21
+
22
+ # RFL: authentication package
23
+
24
+ Generate and validate JWT tokens.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ RFL.authentication.egg-info/PKG-INFO
4
+ RFL.authentication.egg-info/SOURCES.txt
5
+ RFL.authentication.egg-info/dependency_links.txt
6
+ RFL.authentication.egg-info/requires.txt
7
+ RFL.authentication.egg-info/top_level.txt
8
+ rfl/authentication/__init__.py
9
+ rfl/authentication/errors.py
10
+ rfl/authentication/jwt.py
11
+ rfl/authentication/ldap.py
12
+ rfl/authentication/user.py
@@ -0,0 +1,3 @@
1
+ RFL.core
2
+ PyJWT
3
+ python-ldap
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "RFL.authentication"
7
+ version = "1.0.0"
8
+ description = "Rackslab Foundation Library: authentication package"
9
+ license = {text = "GPLv3+"}
10
+ requires-python = ">=3.6"
11
+ keywords = ["utility", "basic"]
12
+ authors = [
13
+ {name = "Rémi Palancher", email = "remi@rackslab.io"},
14
+ ]
15
+ dependencies = [
16
+ "RFL.core",
17
+ "PyJWT",
18
+ "python-ldap",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 5 - Production/Stable",
22
+ "Environment :: Console",
23
+ "Intended Audience :: System Administrators",
24
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
25
+ "Programming Language :: Python :: 3",
26
+ "Topic :: System :: Systems Administration",
27
+ ]
28
+ readme = "README.md"
29
+
30
+ [project.urls]
31
+ "Homepage" = "https://github.com/rackslab/RFL"
32
+ "Bug Tracker" = "https://github.com/rackslab/RFL/issues"
33
+
34
+ [tool.setuptools.packages.find]
35
+ include = ["rfl.authentication*"]
@@ -0,0 +1,35 @@
1
+ # Copyright (c) 2023 Rackslab
2
+ #
3
+ # This file is part of RFL.
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-or-later
6
+
7
+ from rfl.core.errors import RFLError
8
+
9
+
10
+ class AuthenticationError(RFLError):
11
+ pass
12
+
13
+
14
+ class LDAPAuthenticationError(AuthenticationError):
15
+ pass
16
+
17
+
18
+ class JWTError(AuthenticationError):
19
+ pass
20
+
21
+
22
+ class JWTPrivateKeyGeneratorError(JWTError):
23
+ pass
24
+
25
+
26
+ class JWTPrivateKeyLoaderError(JWTError):
27
+ pass
28
+
29
+
30
+ class JWTDecodeError(JWTError):
31
+ pass
32
+
33
+
34
+ class JWTEncodeError(JWTError):
35
+ pass
@@ -0,0 +1,170 @@
1
+ # Copyright (c) 2023 Rackslab
2
+ #
3
+ # This file is part of RFL.
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-or-later
6
+
7
+ import secrets
8
+ from datetime import datetime, timezone, timedelta
9
+ import logging
10
+ from pathlib import Path
11
+
12
+ import jwt
13
+
14
+ from .user import AuthenticatedUser
15
+ from .errors import (
16
+ JWTPrivateKeyGeneratorError,
17
+ JWTPrivateKeyLoaderError,
18
+ JWTDecodeError,
19
+ JWTEncodeError,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def jwt_gen_key(path: Path):
26
+ """Generate JWT private key with random data. Raise JWTPrivateKeyGeneratorError in
27
+ case of error."""
28
+ logger.info("Generating JWT private key file %s", path)
29
+ try:
30
+ with open(path, "w+") as fh:
31
+ fh.write(secrets.token_hex(32))
32
+ except (FileNotFoundError, NotADirectoryError, PermissionError) as err:
33
+ raise JWTPrivateKeyGeneratorError(
34
+ f"Error while generating JWT key {path}: {err}"
35
+ ) from err
36
+ path.chmod(0o400) # restrict access to encryption key
37
+
38
+
39
+ class JWTPrivateKeyLoader:
40
+ pass
41
+
42
+
43
+ class JWTPrivateKeyFileLoader(JWTPrivateKeyLoader):
44
+ def __init__(
45
+ self,
46
+ path: Path = None,
47
+ value: str = None,
48
+ create: bool = False,
49
+ create_parent: bool = False,
50
+ ):
51
+ if path is not None:
52
+ logger.debug("Loading JWT private key from path %s", path)
53
+ self.path = path
54
+ self.key = None
55
+ self._load_path(create, create_parent)
56
+ elif value is not None:
57
+ logger.debug("Loading JWT private key from value")
58
+ self.path = None
59
+ self.key = value
60
+ else:
61
+ raise JWTPrivateKeyLoaderError(
62
+ "Either key value or a path must be given to load JWT private key"
63
+ )
64
+
65
+ def _load_path(self, create: bool, create_parent: bool) -> None:
66
+ """Load the JWT private key from path. If create argument is True, the private
67
+ key is randomly generated if not present. If create_parent is True, the parent
68
+ directory of the private key is also created if not present. Raise
69
+ JWTPrivateKeyLoaderError if create_parent is False and private parent directory
70
+ does not exist or if create is False and private key file does not exist."""
71
+ # Create tokens directory if missing
72
+ if not self.path.parent.exists():
73
+ if create_parent:
74
+ logger.info("Creating JWT private key directory %s", self.path.parent)
75
+ try:
76
+ self.path.parent.mkdir()
77
+ self.path.parent.chmod(0o755) # be umask agnostic
78
+ except PermissionError as err:
79
+ raise JWTPrivateKeyLoaderError(
80
+ "Permission denied to create key parent directory "
81
+ f"{self.path.parent}"
82
+ ) from err
83
+ else:
84
+ raise JWTPrivateKeyLoaderError(
85
+ f"Token private key parent directory {self.path.parent} not found"
86
+ )
87
+ # Generate instance tokens encryption key file if missing
88
+ if not self.path.exists():
89
+ if create:
90
+ jwt_gen_key(self.path)
91
+ else:
92
+ raise JWTPrivateKeyLoaderError(
93
+ f"Token private key file {self.path} not found"
94
+ )
95
+ # Load the instance tokens encryption key
96
+ try:
97
+ with open(self.path, "r") as fh:
98
+ self.key = fh.read()
99
+ except PermissionError as err:
100
+ raise JWTPrivateKeyLoaderError(
101
+ f"Permission error to access private key file {self.path}"
102
+ ) from err
103
+ if not len(self.key):
104
+ raise JWTPrivateKeyLoaderError(f"Key loaded from file {self.path} is empty")
105
+
106
+
107
+ class JWTManager:
108
+ def __init__(self, audience: str, algorithm: str, loader: JWTPrivateKeyLoader):
109
+ self.audience = audience
110
+ self.algorithm = algorithm
111
+ self.key = loader.key
112
+
113
+ def decode(self, token) -> AuthenticatedUser:
114
+ """Decode the given token with the encryption key an returns the user of
115
+ this token."""
116
+ try:
117
+ payload = jwt.decode(
118
+ token,
119
+ self.key,
120
+ audience=self.audience,
121
+ algorithms=[self.algorithm],
122
+ )
123
+ except jwt.InvalidSignatureError as err:
124
+ raise JWTDecodeError("Token signature is invalid") from err
125
+ except jwt.ExpiredSignatureError as err:
126
+ raise JWTDecodeError("Token is expired") from err
127
+ except jwt.InvalidAudienceError as err:
128
+ raise JWTDecodeError("Token audience is invalid") from err
129
+ except jwt.exceptions.DecodeError as err:
130
+ raise JWTDecodeError(f"Unable to decode token: {str(err)}") from err
131
+ return AuthenticatedUser(login=payload["sub"], groups=payload["groups"])
132
+
133
+ def generate(self, user: AuthenticatedUser, duration: int) -> str:
134
+ """Returns a JWT token for the given user, signed with the encryption
135
+ key, for the configured audience and valid for the given duration."""
136
+ try:
137
+ token = jwt.encode(
138
+ {
139
+ "iat": datetime.now(tz=timezone.utc),
140
+ "exp": datetime.now(tz=timezone.utc) + timedelta(days=duration),
141
+ "aud": self.audience,
142
+ "sub": user.login,
143
+ "groups": user.groups,
144
+ },
145
+ self.key,
146
+ algorithm=self.algorithm,
147
+ )
148
+ except NotImplementedError as err:
149
+ raise JWTEncodeError(f"JWT token encode error: {str(err)}") from err
150
+ # For compatibility with PyJWT < 2
151
+ if isinstance(token, bytes):
152
+ return token.decode()
153
+ return token
154
+
155
+ @classmethod
156
+ def key(
157
+ cls,
158
+ audience: str,
159
+ algorithm: str,
160
+ path: Path,
161
+ create: bool = False,
162
+ create_parent: bool = False,
163
+ ):
164
+ return cls(
165
+ audience,
166
+ algorithm,
167
+ JWTPrivateKeyFileLoader(
168
+ path=path, create=create, create_parent=create_parent
169
+ ),
170
+ )
@@ -0,0 +1,315 @@
1
+ # Copyright (c) 2023 Rackslab
2
+ #
3
+ # This file is part of RFL.
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-or-later
6
+
7
+ from typing import Union, List, Tuple
8
+ from pathlib import Path
9
+ import logging
10
+
11
+ import ldap
12
+
13
+ from .user import AuthenticatedUser
14
+ from .errors import LDAPAuthenticationError
15
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class LDAPAuthentifier:
21
+ def __init__(
22
+ self,
23
+ uri,
24
+ cacert: Union[Path, None],
25
+ user_base: str,
26
+ user_class: str,
27
+ group_base: str,
28
+ user_fullname_attribute: str,
29
+ group_name_attribute: str,
30
+ starttls: bool = False,
31
+ bind_dn: Union[str, None] = None,
32
+ bind_password: Union[str, None] = None,
33
+ restricted_groups: Union[List[str], None] = None,
34
+ ):
35
+ self.uri = uri
36
+ self.cacert = cacert
37
+ self.user_base = user_base
38
+ self.user_class = user_class
39
+ self.group_base = group_base
40
+ self.user_fullname_attribute = user_fullname_attribute
41
+ self.group_name_attribute = group_name_attribute
42
+ self.starttls = starttls
43
+ self.bind_dn = bind_dn
44
+ self.bind_password = bind_password
45
+ self.restricted_groups = restricted_groups
46
+
47
+ def connection(self):
48
+ connection = ldap.initialize(self.uri.geturl())
49
+ # SSL/TLS setup
50
+ if self.uri.geturl().startswith("ldaps") or self.starttls:
51
+ connection.protocol_version = ldap.VERSION3
52
+ # Force server certificate validation
53
+ connection.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND)
54
+ # For LDAPS and STARTTLS, libldap require the path to CA certificates to be
55
+ # defined to authentication server certificate. If the cacert option is
56
+ # defined, use it else use default system CA certificates directory defined
57
+ # in OpenSSL library.
58
+ if self.cacert is None:
59
+ import ssl
60
+
61
+ logger.debug(
62
+ "Using default system OpenSSL CA certificate directory to "
63
+ "authenticate server"
64
+ )
65
+ connection.set_option(
66
+ ldap.OPT_X_TLS_CACERTDIR,
67
+ ssl.get_default_verify_paths().openssl_capath,
68
+ )
69
+ else:
70
+ logger.debug(
71
+ "Using CA certification %s to authenticate server", self.cacert
72
+ )
73
+ connection.set_option(ldap.OPT_X_TLS_CACERTFILE, str(self.cacert))
74
+ # Force libldap to create a new SSL context
75
+ try:
76
+ connection.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
77
+ except ValueError as err:
78
+ raise LDAPAuthenticationError(
79
+ "LDAP connection option value error"
80
+ ) from err
81
+ if self.starttls:
82
+ try:
83
+ logger.debug("Using STARTTLS to initialize TLS connection")
84
+ connection.start_tls_s()
85
+ except ldap.CONNECT_ERROR as err:
86
+ raise LDAPAuthenticationError(
87
+ f"Unable to connect to LDAP server with STARTTLS: {str(err)}"
88
+ ) from err
89
+ except ldap.SERVER_DOWN as err:
90
+ raise LDAPAuthenticationError(
91
+ f"LDAP server {self.uri.geturl()} is unreachable"
92
+ ) from err
93
+ return connection
94
+
95
+ def _get_user_info(
96
+ self, connection: ldap.ldapobject.LDAPObject, user_dn: str
97
+ ) -> Tuple[str, int]:
98
+ """Return fullname and gidNumber from the provided user DN."""
99
+ search_filter = f"(objectClass={self.user_class})"
100
+ try:
101
+ results = connection.search_s(
102
+ user_dn,
103
+ ldap.SCOPE_BASE,
104
+ search_filter,
105
+ [self.user_fullname_attribute, "gidNumber"],
106
+ )
107
+ except ldap.NO_SUCH_OBJECT as err:
108
+ raise LDAPAuthenticationError(f"Unable to find user DN {user_dn}") from err
109
+ logger.debug(
110
+ "LDAP search base: %s, scope: base filter: %s, results: %s",
111
+ user_dn,
112
+ search_filter,
113
+ str(results),
114
+ )
115
+ if not len(results):
116
+ raise LDAPAuthenticationError(
117
+ f"User not found in LDAP with class {self.user_class}"
118
+ )
119
+ try:
120
+ fullname = results[0][1][self.user_fullname_attribute][0].decode()
121
+ except KeyError as err:
122
+ raise LDAPAuthenticationError(
123
+ f"Unable to extract user full name with {self.group_name_attribute} "
124
+ "attribute from user entries"
125
+ ) from err
126
+ try:
127
+ gidNumber = int(results[0][1]["gidNumber"][0])
128
+ except KeyError as err:
129
+ raise LDAPAuthenticationError(
130
+ "Unable to extract user primary group with gidNumber attribute from "
131
+ "user entries"
132
+ ) from err
133
+ return fullname, gidNumber
134
+
135
+ def _get_groups(
136
+ self,
137
+ connection: ldap.ldapobject.LDAPObject,
138
+ user_name: str,
139
+ user_dn: str,
140
+ gidNumber: int,
141
+ ) -> List[str]:
142
+ """Return the list of groups whose provided user is member, including its
143
+ gidNumber. This function supports both RFC 2307 (aka. NIS schema) and RFC 2307
144
+ bis schema."""
145
+ # Standard RFC 2307 (aka. NIS) schema has group entries with posixGroup
146
+ # structural class. Group members are declared with memberUid attributes (with
147
+ # user cn as values).
148
+ #
149
+ # RFC 2307 bis schema has group entries with generally at least groupOfNames
150
+ # class and group members are declared with member attributes (with full user dn
151
+ # as values).
152
+ #
153
+ # In both cases, user primary group declared with gidNumber attribute in user
154
+ # entry must not be forgiven.
155
+ search_filter = (
156
+ "(&"
157
+ "(|(objectClass=posixGroup)(objectClass=groupOfNames))"
158
+ f"(|(memberUid={user_name})(member={user_dn})(gidNumber={gidNumber})))"
159
+ )
160
+ try:
161
+ results = connection.search_s(
162
+ self.group_base,
163
+ ldap.SCOPE_SUBTREE,
164
+ search_filter,
165
+ [self.group_name_attribute],
166
+ )
167
+ except ldap.NO_SUCH_OBJECT as err:
168
+ raise LDAPAuthenticationError(
169
+ f"Unable to find group base {self.group_base}"
170
+ ) from err
171
+ logger.debug(
172
+ "LDAP search base: %s, scope: subtree, filter: %s, results: %s",
173
+ self.group_base,
174
+ search_filter,
175
+ str(results),
176
+ )
177
+ if not len(results):
178
+ logger.warning(
179
+ "Unable to find groups in LDAP for user %s or gidNumber %s",
180
+ user_name,
181
+ gidNumber,
182
+ )
183
+ try:
184
+ return [
185
+ result[1][self.group_name_attribute][0].decode() for result in results
186
+ ]
187
+ except KeyError as err:
188
+ raise LDAPAuthenticationError(
189
+ f"Unable to extract group name with {self.group_name_attribute} "
190
+ "attribute from group entries"
191
+ ) from err
192
+
193
+ def _in_restricted_groups(self, groups: List[str]):
194
+ """Return False if restricted groups are set and none of the groups in argument
195
+ matches the restricted groups. If either restricted groups are unset or any of
196
+ the groups in argument is in restricted group, True is returned."""
197
+ return not (
198
+ self.restricted_groups is not None
199
+ and len(self.restricted_groups)
200
+ and not any([group in self.restricted_groups for group in groups])
201
+ )
202
+
203
+ def login(self, user: str, password: str) -> AuthenticatedUser:
204
+ """Verify provided user/password are valid and return the corresponding
205
+ AuthenticatedUser. Raise LDAPAuthenticationError if restricted groups are set
206
+ and the user in not member of any of these groups."""
207
+ fullname = None
208
+ groups = None
209
+ connection = self.connection()
210
+ if user is None or password is None:
211
+ raise LDAPAuthenticationError("Invalid authentication request")
212
+ try:
213
+ # Try simple authentication with user/password on LDAP directory
214
+ user_dn = f"uid={user},{self.user_base}"
215
+ connection.simple_bind_s(user_dn, password)
216
+ fullname, gidNumber = self._get_user_info(connection, user_dn)
217
+ groups = self._get_groups(connection, user, user_dn, gidNumber)
218
+ except ldap.SERVER_DOWN as err:
219
+ raise LDAPAuthenticationError(
220
+ f"LDAP server {self.uri.geturl()} is unreachable"
221
+ ) from err
222
+ except ldap.INVALID_CREDENTIALS as err:
223
+ raise LDAPAuthenticationError("Invalid user or password") from err
224
+ except ldap.UNWILLING_TO_PERFORM as err:
225
+ raise LDAPAuthenticationError(
226
+ f"LDAP server is unwilling to perform: {str(err)}"
227
+ ) from err
228
+ finally:
229
+ connection.unbind_s()
230
+ if not self._in_restricted_groups(groups):
231
+ raise LDAPAuthenticationError(
232
+ f"User {user} is not member of restricted groups"
233
+ )
234
+ return AuthenticatedUser(login=user, fullname=fullname, groups=groups)
235
+
236
+ def _list_user_dn(self, connection):
237
+ """Return list of all users name/pairs pairs in LDAP directory."""
238
+ search_filter = f"(objectClass={self.user_class})"
239
+ try:
240
+ results = connection.search_s(
241
+ self.user_base,
242
+ ldap.SCOPE_SUBTREE,
243
+ search_filter,
244
+ ["uid"],
245
+ )
246
+ except ldap.NO_SUCH_OBJECT as err:
247
+ raise LDAPAuthenticationError(
248
+ f"Unable to find user base {self.user_base}"
249
+ ) from err
250
+ logger.debug(
251
+ "LDAP search base: %s, scope: subtree, filter: %s, results: %s",
252
+ self.group_base,
253
+ search_filter,
254
+ str(results),
255
+ )
256
+ if not len(results):
257
+ logger.warning(
258
+ "Unable to find users in LDAP in base %s subtree",
259
+ self.user_base,
260
+ )
261
+ try:
262
+ return [(result[1]["uid"][0].decode(), result[0]) for result in results]
263
+ except KeyError as err:
264
+ raise LDAPAuthenticationError(
265
+ "Unable to extract user uid from user entries"
266
+ ) from err
267
+
268
+ def users(self, with_groups: bool = False) -> List[AuthenticatedUser]:
269
+ """Return list of AuthenticatedUser available in LDAP directory. If with_groups
270
+ is True, the groups attribute of the AuthenticatedUsers is also initialized with
271
+ the list of their groups. If with_groups is True and restricted groups are set,
272
+ all users whose groups do not match any of the restricted groups are
273
+ discarded."""
274
+ result = []
275
+ connection = self.connection()
276
+
277
+ if self.bind_dn is not None:
278
+ logger.debug("Using DN %s to bind to LDAP directory", self.bind_dn)
279
+ try:
280
+ assert self.bind_password is not None
281
+ except AssertionError as err:
282
+ raise LDAPAuthenticationError(
283
+ f"Password to authenticate with bind DN {self.bind_dn} is required"
284
+ ) from err
285
+ try:
286
+ connection.simple_bind_s(self.bind_dn, self.bind_password)
287
+ except ldap.INVALID_CREDENTIALS as err:
288
+ raise LDAPAuthenticationError("Invalid bind DN or password") from err
289
+
290
+ try:
291
+ for (user, user_dn) in self._list_user_dn(connection):
292
+ fullname, gidNumber = self._get_user_info(connection, user_dn)
293
+ groups = []
294
+ if with_groups:
295
+ groups = self._get_groups(connection, user, user_dn, gidNumber)
296
+ # Skip the user if not member of any of the restricted groups
297
+ if not self._in_restricted_groups(groups):
298
+ logger.debug(
299
+ "Discarding user %s not member of restricted groups", user
300
+ )
301
+ continue
302
+ result.append(
303
+ AuthenticatedUser(login=user, fullname=fullname, groups=groups)
304
+ )
305
+ except ldap.SERVER_DOWN as err:
306
+ raise LDAPAuthenticationError(
307
+ f"LDAP server {self.uri.geturl()} is unreachable"
308
+ ) from err
309
+ except ldap.UNWILLING_TO_PERFORM as err:
310
+ raise LDAPAuthenticationError(
311
+ f"LDAP server is unwilling to perform: {str(err)}"
312
+ ) from err
313
+ finally:
314
+ connection.unbind_s()
315
+ return result
@@ -0,0 +1,19 @@
1
+ # Copyright (c) 2023 Rackslab
2
+ #
3
+ # This file is part of RFL.
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-or-later
6
+
7
+ from typing import Union, List
8
+
9
+
10
+ class AuthenticatedUser:
11
+ def __init__(
12
+ self, login: str, fullname: Union[str, None] = None, groups: List[str] = []
13
+ ):
14
+ self.login = login
15
+ self.fullname = fullname
16
+ self.groups = groups
17
+
18
+ def __str__(self) -> str:
19
+ return f"{self.login} ({self.fullname or '∅'}) [{', '.join(self.groups)}]"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+