Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add oauth2 web flow #953

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ __pycache__/
*.swp
*.zip
*~
build/bdist*
dist/
nagstamon
nagstamon.conf/
nagstamon.egg-info/
/build/bdist*
/dist/
/nagstamon.conf/
/nagstamon.egg-info/
/certifi/
6 changes: 3 additions & 3 deletions Nagstamon/Helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,15 +429,15 @@ def compare_status_information(item):
return(item.lower())


def webbrowser_open(url):
def webbrowser_open(url, new=0):
"""
decide if default or custom browser is used for various tasks
used by almost all
"""
if conf.use_default_browser:
webbrowser.open(url)
webbrowser.open(url, new=new)
else:
webbrowser.get('{0} %s &'.format(conf.custom_browser)).open(url)
webbrowser.get('{0} %s &'.format(conf.custom_browser)).open(url, new=new)


def get_distro():
Expand Down
27 changes: 23 additions & 4 deletions Nagstamon/QUI/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5815,6 +5815,11 @@ def __init__(self, dialog):
self.window.label_idp_ecp_endpoint,
self.window.input_lineedit_idp_ecp_endpoint]

self.AUTHENTICATION_OAUTH2_WEBFLOW_WIDGETS = [
self.window.label_openid_configuration_endpoint,
self.window.input_lineedit_idp_ecp_endpoint
]

# fill default order fields combobox with monitor server types
self.window.input_combobox_type.addItems(sorted(SERVER_TYPES.keys(), key=str.lower))
# default to Nagios as it is the mostly used monitor server
Expand All @@ -5828,14 +5833,14 @@ def __init__(self, dialog):
self.window.button_choose_custom_cert_ca_file.clicked.connect(self.choose_custom_cert_ca_file)

# fill authentication combobox
self.window.input_combobox_authentication.addItems(['Basic', 'Digest', 'Kerberos', 'Bearer'])
self.window.input_combobox_authentication.addItems(['Basic', 'Digest', 'Kerberos', 'Bearer', 'OAuth2 Web Flow'])
if ECP_AVAILABLE is True:
self.window.input_combobox_authentication.addItems(['ECP'])

# detect change of server type which leads to certain options shown or hidden
self.window.input_combobox_type.activated.connect(self.toggle_type)

# when authentication is changed to Kerberos then disable username/password as the are now useless
# when authentication is changed to Kerberos then disable username/password as they are now useless
self.window.input_combobox_authentication.activated.connect(self.toggle_authentication)

# reset Checkmk views
Expand Down Expand Up @@ -5874,15 +5879,29 @@ def toggle_authentication(self):
for widget in self.AUTHENTICATION_ECP_WIDGETS:
widget.hide()

if self.window.input_combobox_authentication.currentText() == 'OAuth2 Web Flow':
for widget in self.AUTHENTICATION_OAUTH2_WEBFLOW_WIDGETS:
widget.show()
else:
for widget in self.AUTHENTICATION_OAUTH2_WEBFLOW_WIDGETS:
widget.hide()

# change credential input for bearer auth
if self.window.input_combobox_authentication.currentText() == 'Bearer':
for widget in self.AUTHENTICATION_BEARER_WIDGETS:
widget.hide()
self.window.label_password.setText('Token')
else:
for widget in self.AUTHENTICATION_BEARER_WIDGETS:
widget.show()
self.window.label_password.setText('Password')

if self.window.input_combobox_authentication.currentText() == 'Bearer':
self.window.label_password.setText('Token:')
elif self.window.input_combobox_authentication.currentText() == 'OAuth2 Web Flow':
self.window.label_username.setText('Client ID:')
self.window.label_password.setText('Client Secret:')
else:
self.window.label_username.setText('Username:')
self.window.label_password.setText('Password:')

# after hiding authentication widgets dialog might shrink
self.window.adjustSize()
Expand Down
20 changes: 18 additions & 2 deletions Nagstamon/Servers/Generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
from bs4 import BeautifulSoup
import requests

from Nagstamon.Servers.Oauth2WebFlow import get_oauth2_session

# check ECP authentication support availability
try:
from requests_ecp import HTTPECPAuth
Expand Down Expand Up @@ -299,7 +301,12 @@ def init_HTTP(self):
self.session = None
return False
elif self.session is None:
self.session = self.create_session()
try:
self.session = self.create_session()
except Exception as e:
print("Error while creating session: {}".format(e))
self.session = None
return False
return True

def create_session(self):
Expand All @@ -309,7 +316,6 @@ def create_session(self):
different between various server types
"""
session = requests.Session()
session.headers['User-Agent'] = self.USER_AGENT

# support for different authentication types
if self.authentication == 'basic':
Expand All @@ -323,6 +329,15 @@ def create_session(self):
session.auth = HTTPSKerberos()
elif self.authentication == 'bearer':
session.auth = BearerAuth(self.password)
elif self.authentication == 'oauth2 web flow':
session = get_oauth2_session(
client_id=self.username,
client_secret=self.password,
openid_config_url=self.idp_ecp_endpoint,
port=9000)
session.headers['X-OAuth2'] = "1"

session.headers['User-Agent'] = self.USER_AGENT

# default to check TLS validity
if self.ignore_cert:
Expand Down Expand Up @@ -1535,6 +1550,7 @@ def FetchURL(self, url, giveback='obj', cgi_data=None, no_auth=False, multipart=
del temporary_session

except Exception:
self.reset_HTTP() # reset session to refresh auth and cookies
if conf.debug_mode:
self.Error(sys.exc_info())
result, error = self.Error(sys.exc_info())
Expand Down
35 changes: 5 additions & 30 deletions Nagstamon/Servers/IcingaDBWebNotifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,41 +17,16 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA

# Initial implementation by Marcus Mönnig
#
# This Server class connects against IcingaWeb2. The monitor URL in the setup should be
# something like http://icinga2/icingaweb2
#
# Status/TODOs:
#
# * The IcingaWeb2 API is not implemented yet, so currently this implementation uses
# two HTTP requests per action. The first fetches the HTML, then the form data is extracted and
# then a second HTTP POST request is made which actually executed the action.
# Once IcingaWeb2 has an API, it's probably the better choice.


from Nagstamon.Servers.Generic import GenericServer
import urllib.parse
# Same as IcingaDBWeb but uses the notification endpoint to allow fine granular recipient management for alerts
import sys
import json
import datetime
import socket

from bs4 import BeautifulSoup
from Nagstamon.Objects import (GenericHost,
GenericService,
Result)
from Nagstamon.Config import (conf,
AppInfo)
from Nagstamon.Helpers import webbrowser_open
from Nagstamon.Servers.IcingaDBWeb import IcingaDBWebServer


def strfdelta(tdelta, fmt):
d = {'days': tdelta.days}
d['hours'], rem = divmod(tdelta.seconds, 3600)
d['minutes'], d['seconds'] = divmod(rem, 60)
return fmt.format(**d)
from Nagstamon.Servers.IcingaDBWeb import IcingaDBWebServer, strfdelta


class IcingaDBWebNotificationsServer(IcingaDBWebServer):
Expand Down Expand Up @@ -137,7 +112,7 @@ def _update_new_host_content(self) -> Result:
self.new_hosts[host_name].status = self.STATES_MAPPING['hosts'][status_numeric]
self.new_hosts[host_name].last_check = datetime.datetime.fromtimestamp(int(float(notification['host']['state']['last_update'])))
self.new_hosts[host_name].attempt = "{}/{}".format(notification['host']['state']['check_attempt'],notification['host']['max_check_attempts'])
self.new_hosts[host_name].status_information = BeautifulSoup(notification['host']['state']['output'].replace('\n', ' ').strip(), 'html.parser').text
self.new_hosts[host_name].status_information = BeautifulSoup(notification['text'].replace('\n', ' ').strip(), 'html.parser').text
self.new_hosts[host_name].passiveonly = not int(notification['host'].get('active_checks_enabled') or '0')
self.new_hosts[host_name].notifications_disabled = not int(notification['host'].get('notifications_enabled') or '0')
self.new_hosts[host_name].flapping = bool(int(notification['host']['state']['is_flapping'] or 0))
Expand Down Expand Up @@ -209,7 +184,7 @@ def _update_new_host_content(self) -> Result:
self.new_hosts[host_name].services[service_name].status = self.STATES_MAPPING['services'][status_numeric]

self.new_hosts[host_name].services[service_name].last_check = datetime.datetime.fromtimestamp(int(float(notification['service']['state']['last_update'])))
self.new_hosts[host_name].services[service_name].status_information = BeautifulSoup(notification['service']['state']['output'].replace('\n', ' ').strip(), 'html.parser').text
self.new_hosts[host_name].services[service_name].status_information = BeautifulSoup(notification['text'].replace('\n', ' ').strip(), 'html.parser').text
self.new_hosts[host_name].services[service_name].passiveonly = not int(notification['service'].get('active_checks_enabled') or '0')
self.new_hosts[host_name].services[service_name].notifications_disabled = not int(notification['service'].get('notifications_enabled') or '0')
self.new_hosts[host_name].services[service_name].flapping = bool(int(notification['service']['state']['is_flapping'] or 0))
Expand Down Expand Up @@ -243,4 +218,4 @@ def _update_new_host_content(self) -> Result:
self.new_hosts[host_name].services[service_name].duration = 'n/a'

# return success
return Result()
return Result()
87 changes: 87 additions & 0 deletions Nagstamon/Servers/Oauth2WebFlow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Oauth2 Authorization Code Grant/Web Application Flow/Standard Flow with local webserver."""
import requests
import http.server
import socketserver
from urllib.parse import urlparse, parse_qs
from requests_oauthlib import OAuth2Session
import logging

from Nagstamon.Helpers import webbrowser_open

log = logging.getLogger(__name__)


class LocalOauthServer(http.server.BaseHTTPRequestHandler):

"""Local Oauth server to receive authorization code."""

def __init__(self, request, client_address, server):
super().__init__(request, client_address, server)
if not hasattr(server, "code"):
server.code = None

def do_GET(self):
url_parse = urlparse(self.path)
params = parse_qs(url_parse.query)
if "code" in params:
self.server.code = params["code"][0]
print(params["code"][0])
self.protocol_version = 'HTTP/1.1'
self.send_response(200, 'OK')
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(bytes(
"<html><head><title>Nagstamon Oauth Login</title>"
"<script>window.close()</script></head>"
"<body>You have logged in Nagstamon. You can close this window.</body></html>",
'UTF-8'))


class OauthTcpServer(socketserver.TCPServer):

def __init__(self, server_address, RequestHandlerClass, bind_and_activate=True):
self.allow_reuse_address = True # we will restart this server frequently
self.code = None # used to store Oauth code from handler
super().__init__(server_address, RequestHandlerClass, bind_and_activate)


def get_oauth2_session(client_id, client_secret, openid_config_url, port: int, timeout: int = 180) -> OAuth2Session:
"""Login via Oauth2 and return an authenticated session."""
redirect_uri = "http://localhost:{}/".format(port)

openid_configuration = requests.get(openid_config_url).json()
oauth = OAuth2Session(client_id=client_id,
redirect_uri=redirect_uri,
auto_refresh_kwargs={
"client_id": client_id,
"client_secret": client_secret
},
auto_refresh_url=openid_configuration["token_endpoint"],
token_updater=lambda x: None
)
authorization_url, state = oauth.authorization_url(openid_configuration["authorization_endpoint"])

server = OauthTcpServer(("localhost", port), LocalOauthServer)
server.timeout = 10
max_tries = 10
tries = max_tries

log.debug("Starting local webserver and opening OAuth provider in browser")
with server as httpd:
while server.code is None and tries > 0:
webbrowser_open(authorization_url, new=2)
httpd.handle_request()
tries -= 1

if server.code is None:
log.warning("Login timed out (%ss) after %s tries.", timeout, max_tries)
raise TimeoutError("Login timed out (%ss).")
else:
log.debug("Received auth code.")

oauth.fetch_token(token_url=openid_configuration["token_endpoint"],
code=server.code,
client_secret=client_secret)
log.debug("Fetched token. Authenticated successfully.")

return oauth
7 changes: 7 additions & 0 deletions Nagstamon/resources/qui/settings_server.ui
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,13 @@
</property>
</widget>
</item>
<item row="26" column="1">
<widget class="QLabel" name="label_openid_configuration_endpoint">
<property name="text">
<string>Open ID Configuration URL</string>
</property>
</widget>
</item>
<item row="12" column="1">
<widget class="QLabel" name="label_alertmanager_filter">
<property name="text">
Expand Down
1 change: 1 addition & 0 deletions build/requirements/linux.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ requests
requests-kerberos
requests-ecp
setuptools
requests_oauthlib
1 change: 1 addition & 0 deletions build/requirements/macos.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ requests-ecp
# gssapi instead kerberos
requests-gssapi
setuptools
requests_oauthlib
1 change: 1 addition & 0 deletions build/requirements/windows.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ requests-ecp
requests-kerberos
setuptools
wheel
requests_oauthlib