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. # one user has been created, overriding the "trusted_clients" configuration.
# If no users have been created then trusted client checks will apply. # If no users have been created then trusted client checks will apply.
# The default is False. # 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]` ### `[octoprint_compat]`

View File

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

View File

@ -32,6 +32,16 @@ from typing import (
Dict, Dict,
List, List,
) )
from bonsai import (
LDAPClient,
LDAPSearchScope
)
from bonsai.errors import (
ConnectionError,
AuthenticationError,
TimeoutError,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from confighelper import ConfigHelper from confighelper import ConfigHelper
from websockets import WebRequest from websockets import WebRequest
@ -73,6 +83,24 @@ class Authorization:
self.server = config.get_server() self.server = config.get_server()
self.login_timeout = config.getint('login_timeout', 90) self.login_timeout = config.getint('login_timeout', 90)
self.force_logins = config.getboolean('force_logins', False) 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: DBComp = self.server.lookup_component('database')
database.register_local_namespace('authorized_users', forbidden=True) database.register_local_namespace('authorized_users', forbidden=True)
self.user_db = database.wrap_namespace('authorized_users') self.user_db = database.wrap_namespace('authorized_users')
@ -252,7 +280,7 @@ class Authorization:
return self.get_oneshot_token(ip, user_info) return self.get_oneshot_token(ip, user_info)
async def _handle_login(self, web_request: WebRequest) -> Dict[str, Any]: 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]: async def _handle_logout(self, web_request: WebRequest) -> Dict[str, str]:
user_info = web_request.get_current_user() user_info = web_request.get_current_user()
@ -288,6 +316,8 @@ class Authorization:
return { return {
'username': username, 'username': username,
'token': token, 'token': token,
'source': '%s' % (user_info['source'] if 'source' in user_info
else "moonraker"),
'action': 'user_jwt_refresh' 'action': 'user_jwt_refresh'
} }
@ -300,16 +330,19 @@ class Authorization:
if user is None: if user is None:
return { return {
'username': None, 'username': None,
'source': None,
'created_on': None, 'created_on': None,
} }
else: else:
return { return {
'username': user['username'], 'username': user['username'],
'source': '%s' % (user['source'] if 'source' in user
else "moonraker"),
'created_on': user.get('created_on') 'created_on': user.get('created_on')
} }
elif action == "POST": elif action == "POST":
# Create User # Create User
return self._login_jwt_user(web_request, create=True) return await self._login_jwt_user(web_request, create=True)
elif action == "DELETE": elif action == "DELETE":
# Delete User # Delete User
return self._delete_jwt_user(web_request) return self._delete_jwt_user(web_request)
@ -324,6 +357,8 @@ class Authorization:
continue continue
user_list.append({ user_list.append({
'username': user['username'], 'username': user['username'],
'source': '%s' % (user['source'] if 'source' in user
else "moonraker"),
'created_on': user['created_on'] 'created_on': user['created_on']
}) })
return { return {
@ -339,6 +374,9 @@ class Authorization:
if user_info is None: if user_info is None:
raise self.server.error("No Current User") raise self.server.error("No Current User")
username = user_info['username'] 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: if username in RESERVED_USERS:
raise self.server.error( raise self.server.error(
f"Invalid Reset Request for user {username}") f"Invalid Reset Request for user {username}")
@ -356,16 +394,64 @@ class Authorization:
'action': "user_password_reset" 'action': "user_password_reset"
} }
def _login_jwt_user(self, 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, web_request: WebRequest,
create: bool = False create: bool = False
) -> Dict[str, Any]: ) -> Dict[str, Any]:
username: str = web_request.get_str('username') username: str = web_request.get_str('username')
password: str = web_request.get_str('password') password: str = web_request.get_str('password')
source: str = web_request.get_str('source', self.default_source).lower()
user_info: Dict[str, Any] user_info: Dict[str, Any]
if username in RESERVED_USERS: if username in RESERVED_USERS:
raise self.server.error( raise self.server.error(
f"Invalid Request for user {username}") 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 create:
if username in self.users: if username in self.users:
raise self.server.error(f"User {username} already exists") raise self.server.error(f"User {username} already exists")
@ -376,11 +462,14 @@ class Authorization:
'username': username, 'username': username,
'password': hashed_pass, 'password': hashed_pass,
'salt': salt.hex(), 'salt': salt.hex(),
'source': source,
'created_on': time.time() 'created_on': time.time()
} }
self.users[username] = user_info self.users[username] = user_info
self._sync_user(username) self._sync_user(username)
action = "user_created" action = "user_created"
if source == "ldap":
action = "user_logged_in"
else: else:
if username not in self.users: if username not in self.users:
raise self.server.error(f"Unregistered User: {username}") raise self.server.error(f"Unregistered User: {username}")
@ -416,6 +505,8 @@ class Authorization:
return { return {
'username': username, 'username': username,
'token': token, 'token': token,
'source': '%s' % (user_info['source'] if 'source' in user_info
else "moonraker"),
'refresh_token': refresh_token, 'refresh_token': refresh_token,
'action': action 'action': action
} }
@ -531,6 +622,9 @@ class Authorization:
'use': "sig" '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: def _public_key_from_jwk(self, jwk: Dict[str, Any]) -> Verifier:
if jwk.get('kty') != "OKP": if jwk.get('kty') != "OKP":
raise self.server.error("Not an Octet Key Pair") raise self.server.error("Not an Octet Key Pair")
@ -641,8 +735,8 @@ class Authorization:
def check_authorized(self, def check_authorized(self,
request: HTTPServerRequest request: HTTPServerRequest
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
if request.path in self.permitted_paths or \ if request.path in self.permitted_paths \
request.method == "OPTIONS": or request.method == "OPTIONS":
return None return None
# Check JSON Web Token # Check JSON Web Token

View File

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