RFL.authentication 1.0.0__py3-none-any.whl
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.
- RFL.authentication-1.0.0.dist-info/METADATA +24 -0
- RFL.authentication-1.0.0.dist-info/RECORD +9 -0
- RFL.authentication-1.0.0.dist-info/WHEEL +5 -0
- RFL.authentication-1.0.0.dist-info/top_level.txt +1 -0
- rfl/authentication/__init__.py +0 -0
- rfl/authentication/errors.py +35 -0
- rfl/authentication/jwt.py +170 -0
- rfl/authentication/ldap.py +315 -0
- rfl/authentication/user.py +19 -0
|
@@ -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,9 @@
|
|
|
1
|
+
rfl/authentication/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
rfl/authentication/errors.py,sha256=BCdOrQRdj1k5Co_HW-f8bhs0Su96hkcqMNN2gjFGkxA,498
|
|
3
|
+
rfl/authentication/jwt.py,sha256=nxFrScrDO4fYVF_LXQJ642ayQbqXAa09vffjJqbWz_k,6173
|
|
4
|
+
rfl/authentication/ldap.py,sha256=LzRISee_T7n20YyzVFPUlxcaOoXHAJoA9YIK3a3oVx8,12827
|
|
5
|
+
rfl/authentication/user.py,sha256=lFYJCscipF5SpEYwr8-wYAt5P3cDxLYaTUQzvR2U_Y0,479
|
|
6
|
+
RFL.authentication-1.0.0.dist-info/METADATA,sha256=ZepYHW3TA4L5yRRH5IJFWlbGCcxaibDc3ocojKHEJwk,868
|
|
7
|
+
RFL.authentication-1.0.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
8
|
+
RFL.authentication-1.0.0.dist-info/top_level.txt,sha256=EIloiMrXuRz8n0vqkCE6NM9Ze1S2mrbCKBcdtWaM_og,4
|
|
9
|
+
RFL.authentication-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rfl
|
|
File without changes
|
|
@@ -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)}]"
|