authorization: add LDAP support

Signed-off-by: Luca Schöneberg luca-schoeneberg@outlook.com
This commit is contained in:
Luca Schöneberg 2022-06-07 12:46:08 +02:00 committed by GitHub
parent 7af8a03129
commit a86cbc77f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 137 additions and 14 deletions

View File

@ -285,6 +285,28 @@ force_logins: False
# one user has been created, overriding the "trusted_clients" configuration.
# If no users have been created then trusted client checks will apply.
# The default is False.
default_source: moonraker
# If the default_source is set to "ldap", a user login is required for authorization.
# The default_source is set to "moonraker" by default.
# Providing a correct configuration for an LDAP session
# is required because moonraker does not verify the provided configuration.
ldap_server: ldap.local
ldap_base_dn: DC=ldap,DC=local
ldap_secure: True
# To use LDAPs(LDAP over SSL/TLS), please set ldap_secure to True.
ldap_type_ad: True
# Set ldap_type_ad to True if you use Microsoft Active Directory.
ldap_bind_dn: {secrets.ldap_credentials.bind_dn}
# The distinguished name for bind authentication. It should look like this
# CN=moonraker,OU=Users,DC=ldap,DC=local. This option accepts
# Jinja2 Templates, see the [secrets] section for details.
ldap_bind_password: {secrets.ldap_credentials.bind_password}
# The password for bind authentication. This option accepts
# Jinja2 Templates, see the [secrets] section for details.
ldap_group_dn: CN=moonraker,OU=Groups,DC=ldap,DC=local
# The ldap_group_dn must be in the memberOf list of the user.
# If this option is not filled, a successful authentication is enough.
```
### `[octoprint_compat]`

View File

@ -1949,11 +1949,12 @@ GET /access/user
```
JSON-RPC request: Not Available
Returns: An object containing the currently logged in user name and
Returns: An object containing the currently logged in user name, the source and
the date on which the user was created (in unix time).
```json
{
"username": "my_user",
"source": "moonraker",
"created_on": 1618876783.8896716
}
```
@ -1966,19 +1967,21 @@ Content-Type: application/json
{
"username": "my_user",
"password": "my_password"
"password": "my_password",
"source": "moonraker",
}
```
JSON-RPC request: Not Available
Returns: An object containing the created user name, an auth token,
a refresh token, and an action summary. Creating a user also effectively
a refresh token, the source, and an action summary. Creating a user also effectively
logs the user in.
```json
{
"username": "my_user",
"token": "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiTW9vbnJha2VyIiwgImlhdCI6IDE2MTg4NzY3ODMuODkxNjE5LCAiZXhwIjogMTYxODg4MDM4My44OTE2MTksICJ1c2VybmFtZSI6ICJteV91c2VyIiwgInRva2VuX3R5cGUiOiAiYXV0aCJ9.oH0IShTL7mdlVs4kcx3BIs_-1j0Oe-qXezJKjo-9Xgo",
"refresh_token": "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiTW9vbnJha2VyIiwgImlhdCI6IDE2MTg4NzY3ODMuODkxNzAyNCwgImV4cCI6IDE2MjY2NTI3ODMuODkxNzAyNCwgInVzZXJuYW1lIjogIm15X3VzZXIiLCAidG9rZW5fdHlwZSI6ICJyZWZyZXNoIn0.a6ZeRjk8RQQJDDH0JV-qGY_d_HIgfI3XpsqUlUaFT7c",
"source": "moonraker",
"action": "user_created"
}
```
@ -2028,10 +2031,12 @@ Returns: A list of created users on the system
"users": [
{
"username": "testuser",
"source": "moonraker",
"created_on": 1618771331.1685035
},
{
"username": "testuser2",
"source": "ldap",
"created_on": 1620943153.0191233
}
]
@ -2076,11 +2081,12 @@ Content-Type: application/json
JSON-RPC request: Not Available
Returns: The username, new auth token, and action summary.
Returns: The username, new auth token, the source and action summary.
```json
{
"username": "my_user",
"token": "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3MiOiAiTW9vbnJha2VyIiwgImlhdCI6IDE2MTg4NzgyNDMuNTE2Nzc5MiwgImV4cCI6IDE2MTg4ODE4NDMuNTE2Nzc5MiwgInVzZXJuYW1lIjogInRlc3R1c2VyIiwgInRva2VuX3R5cGUiOiAiYXV0aCJ9.Ia_X_pf20RR4RAEXcxalZIOzOBOs2OwearWHfRnTSGU",
"source": "moonraker",
"action": "user_jwt_refresh"
}
```

View File

@ -32,6 +32,16 @@ from typing import (
Dict,
List,
)
from bonsai import (
LDAPClient,
LDAPSearchScope
)
from bonsai.errors import (
ConnectionError,
AuthenticationError,
TimeoutError,
)
if TYPE_CHECKING:
from confighelper import ConfigHelper
from websockets import WebRequest
@ -73,6 +83,24 @@ class Authorization:
self.server = config.get_server()
self.login_timeout = config.getint('login_timeout', 90)
self.force_logins = config.getboolean('force_logins', False)
self.ldap_server = config.get('ldap_server', None)
self.ldap_base_dn = config.get('ldap_base_dn', None)
self.ldap_group_dn = config.get('ldap_group_dn', None)
self.ldap_type_ad = config.getboolean('ldap_type_ad', False)
self.ldap_secure = config.getboolean('ldap_secure', False)
ldap_bind_dn_template = config.gettemplate('ldap_bind_dn', None)
self.ldap_bind_dn: Optional[str] = None
if ldap_bind_dn_template is not None:
self.ldap_bind_dn = ldap_bind_dn_template.render()
ldap_bind_password_template = config.gettemplate('ldap_bind_password',
None)
self.ldap_bind_password: Optional[str] = None
if ldap_bind_password_template is not None:
self.ldap_bind_password = ldap_bind_password_template.render()
self.default_source = config.get('default_source', "moonraker")
database: DBComp = self.server.lookup_component('database')
database.register_local_namespace('authorized_users', forbidden=True)
self.user_db = database.wrap_namespace('authorized_users')
@ -252,7 +280,7 @@ class Authorization:
return self.get_oneshot_token(ip, user_info)
async def _handle_login(self, web_request: WebRequest) -> Dict[str, Any]:
return self._login_jwt_user(web_request)
return await self._login_jwt_user(web_request)
async def _handle_logout(self, web_request: WebRequest) -> Dict[str, str]:
user_info = web_request.get_current_user()
@ -288,6 +316,8 @@ class Authorization:
return {
'username': username,
'token': token,
'source': '%s' % (user_info['source'] if 'source' in user_info
else "moonraker"),
'action': 'user_jwt_refresh'
}
@ -300,16 +330,19 @@ class Authorization:
if user is None:
return {
'username': None,
'source': None,
'created_on': None,
}
else:
return {
'username': user['username'],
'source': '%s' % (user['source'] if 'source' in user
else "moonraker"),
'created_on': user.get('created_on')
}
elif action == "POST":
# Create User
return self._login_jwt_user(web_request, create=True)
return await self._login_jwt_user(web_request, create=True)
elif action == "DELETE":
# Delete User
return self._delete_jwt_user(web_request)
@ -324,6 +357,8 @@ class Authorization:
continue
user_list.append({
'username': user['username'],
'source': '%s' % (user['source'] if 'source' in user
else "moonraker"),
'created_on': user['created_on']
})
return {
@ -339,6 +374,9 @@ class Authorization:
if user_info is None:
raise self.server.error("No Current User")
username = user_info['username']
if user_info['source'] == "ldap":
raise self.server.error(
f"Can´t Reset password for ldap user {username}")
if username in RESERVED_USERS:
raise self.server.error(
f"Invalid Reset Request for user {username}")
@ -356,16 +394,64 @@ class Authorization:
'action': "user_password_reset"
}
def _login_jwt_user(self,
web_request: WebRequest,
create: bool = False
) -> Dict[str, Any]:
async def _login_ldap_user(self, username, password) -> bool:
if self.ldap_server is None or self.ldap_base_dn is None \
or self.ldap_group_dn is None \
or self.ldap_bind_password is None \
or self.ldap_bind_dn is None:
raise self.server.error(
"ldap: Configuration is not given", 401
)
base_dn = str(self.ldap_base_dn)
client = LDAPClient(self._generate_ldap_url_(str(self.ldap_server)))
client.set_credentials("SIMPLE", self.ldap_bind_dn,
self.ldap_bind_password)
client.set_cert_policy("allow")
bind_success = False
try:
async with client.connect(is_async=True, timeout=10) as conn:
ldap_filter = ("(&(objectClass=Person)(%s=" + '%s))') % \
("sAMAccountName"
if self.ldap_type_ad
else "uid",
username)
user = await conn.search(
base_dn,
LDAPSearchScope.SUBTREE,
ldap_filter,
['memberOf']
)
auth_username = str(user[0]["DN"])
client.set_credentials("SIMPLE", auth_username, password)
bind_success = True
async with client.connect(is_async=True, timeout=10):
if self.ldap_group_dn is None:
return True
if len(user[0]['memberOf']) > 0:
for group in user[0]['memberOf']:
if str(group) == str(self.ldap_group_dn):
return True
except (ConnectionError, AuthenticationError, TimeoutError):
if not bind_success:
raise self.server.error("ldap: bind error", 401)
raise self.server.error("ldap: Invalid Username or Password",
401)
async def _login_jwt_user(self,
web_request: WebRequest,
create: bool = False
) -> Dict[str, Any]:
username: str = web_request.get_str('username')
password: str = web_request.get_str('password')
source: str = web_request.get_str('source', self.default_source).lower()
user_info: Dict[str, Any]
if username in RESERVED_USERS:
raise self.server.error(
f"Invalid Request for user {username}")
if source == "ldap" and not create:
await self._login_ldap_user(username, password)
if username not in self.users:
create = True
if create:
if username in self.users:
raise self.server.error(f"User {username} already exists")
@ -376,11 +462,14 @@ class Authorization:
'username': username,
'password': hashed_pass,
'salt': salt.hex(),
'source': source,
'created_on': time.time()
}
self.users[username] = user_info
self._sync_user(username)
action = "user_created"
if source == "ldap":
action = "user_logged_in"
else:
if username not in self.users:
raise self.server.error(f"Unregistered User: {username}")
@ -389,8 +478,8 @@ class Authorization:
hashed_pass = hashlib.pbkdf2_hmac(
'sha256', password.encode(), salt, HASH_ITER).hex()
action = "user_logged_in"
if hashed_pass != user_info['password']:
raise self.server.error("Invalid Password")
if hashed_pass != user_info['password']:
raise self.server.error("Invalid Password")
jwt_secret_hex: Optional[str] = user_info.get('jwt_secret', None)
if jwt_secret_hex is None:
private_key = Signer()
@ -416,6 +505,8 @@ class Authorization:
return {
'username': username,
'token': token,
'source': '%s' % (user_info['source'] if 'source' in user_info
else "moonraker"),
'refresh_token': refresh_token,
'action': action
}
@ -531,6 +622,9 @@ class Authorization:
'use': "sig"
}
def _generate_ldap_url_(self, url: str) -> str:
return "ldap%s://%s" % ("s" if self.ldap_secure else "", url)
def _public_key_from_jwk(self, jwk: Dict[str, Any]) -> Verifier:
if jwk.get('kty') != "OKP":
raise self.server.error("Not an Octet Key Pair")
@ -641,8 +735,8 @@ class Authorization:
def check_authorized(self,
request: HTTPServerRequest
) -> Optional[Dict[str, Any]]:
if request.path in self.permitted_paths or \
request.method == "OPTIONS":
if request.path in self.permitted_paths \
or request.method == "OPTIONS":
return None
# Check JSON Web Token

View File

@ -15,3 +15,4 @@ preprocess-cancellation==0.2.0
jinja2==3.0.3
dbus-next==0.2.3
apprise==0.9.7
bonsai==1.4.0