From eca9defd628a93c33725907abecff4435d71f616 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 15 Aug 2024 13:14:47 +0000 Subject: [PATCH 1/5] Lease Updates: - Added support for ip based leases - Added support for creating user/name password leases from Slack slash commands --- gui/database/__init__.py | 4 +- gui/modules/api/api.sql | 1 + gui/modules/api/api_functions.py | 5 ++ gui/modules/api/api_routes.py | 89 +++++++++++++++++++++++--------- 4 files changed, 73 insertions(+), 26 deletions(-) diff --git a/gui/database/__init__.py b/gui/database/__init__.py index f524de7a..7deba424 100644 --- a/gui/database/__init__.py +++ b/gui/database/__init__.py @@ -291,12 +291,12 @@ class dSIPLeases(object): maintains a list of active leases based on seconds """ - def __init__(self, gwid, sid, ttl): + def __init__(self, gwid, sid, ttl, addrid=None): self.gwid = gwid self.sid = sid t = datetime.now() + timedelta(seconds=ttl) self.expiration = t.strftime('%Y-%m-%d %H:%M:%S') - + self.addrid = addrid pass diff --git a/gui/modules/api/api.sql b/gui/modules/api/api.sql index cee08ea9..f7d72604 100644 --- a/gui/modules/api/api.sql +++ b/gui/modules/api/api.sql @@ -3,6 +3,7 @@ CREATE TABLE `dsip_endpoint_lease` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, `gwid` int(10) unsigned NOT NULL, `sid` int(10) unsigned NOT NULL, + `addrid` int(10) unsigned NULL, `expiration` datetime NOT NULL, PRIMARY KEY (`id`) ); diff --git a/gui/modules/api/api_functions.py b/gui/modules/api/api_functions.py index 98347140..dee12700 100644 --- a/gui/modules/api/api_functions.py +++ b/gui/modules/api/api_functions.py @@ -110,6 +110,11 @@ def wrapper(*args, **kwargs): msg='Unauthorized - Core Subscription Required. Purchase from https://dopensource.com/product/dsiprouter-core/', status_code=StatusCodes.HTTP_UNAUTHORIZED ) + # Check if request is from Slack and the endpoint is the leasing endpoint + if request.headers.get('X-Slack-Signature'): + #flask_rule[:17] == "/api/v1/lease/endpoint": + # checks succeeded allow the request + return func(*args, **kwargs) # Check if token is valid if not apiToken.isValid(): return createApiResponse( diff --git a/gui/modules/api/api_routes.py b/gui/modules/api/api_routes.py index 8200e934..cc520bbe 100644 --- a/gui/modules/api/api_routes.py +++ b/gui/modules/api/api_routes.py @@ -130,7 +130,7 @@ def handleReloadDsiprouter(): # it should be renamed and changed to use POST method # TODO: the last lease id/username generated must be tracked (just query DB) # and used to determine next lease id, otherwise conflicts may occur -@api.route("/api/v1/lease/endpoint", methods=['GET']) +@api.route("/api/v1/lease/endpoint", methods=['GET','POST']) @api_security def getEndpointLease(): db = DummySession() @@ -149,9 +149,32 @@ def getEndpointLease(): if not email: raise Exception("email parameter is missing") - ttl = request.args.get('ttl', None) - if ttl is None: - raise http_exceptions.BadRequest("time to live (ttl) parameter is missing") + # Grab ttl parameter from Slack + if request.headers.get('X-Slack-Signature'): + text = request.args.get('text', None) + if text is not None: + ttl = text + # Set the ttl to 5 minutes if no ttl from slack is sent + else: + ttl = "5m" + else: + ttl = request.args.get('ttl') + if ttl is None: + raise http_exceptions.BadRequest("time to live (ttl) parameter is missing") + + type = request.args.get('type', None) + if type is None: + type = "userpwd" + elif type != "userpwd" and type != "ip": + raise http_exceptions.BadRequest("type can only have a value of ip or userpwd") + + + auth_ip = request.args.get('auth_ip', None) + if type == "ip" and auth_ip is None: + raise http_exceptions.BadRequest("auth_ip must be provided if the type is ip") + elif type == "userpwd" and auth_ip is not None: + raise http_exceptions.BadRequest("auth_ip is not valid when type is userpwd") + # Convert TTL to Seconds r = re.compile('\d*m|M') @@ -161,33 +184,51 @@ def getEndpointLease(): # Generate some values rand_num = random.randint(1, 200) name = "lease" + str(rand_num) - auth_username = name - auth_password = urandomChars(DEF_PASSWORD_LEN) auth_domain = settings.DEFAULT_AUTH_DOMAIN + + if type == "userpwd": + auth_username = name + auth_password = urandomChars(DEF_PASSWORD_LEN) + + # Set some defaults + host_addr = '' + strip = 0 + prefix = '' + + # Add the Gateways table + Gateway = Gateways(name, host_addr, strip, prefix, settings.FLT_PBX) + db.add(Gateway) + db.flush() - # Set some defaults - host_addr = '' - strip = 0 - prefix = '' + # Add the Subscribers table + Subscriber = Subscribers(auth_username, auth_password, auth_domain, Gateway.gwid, email) + db.add(Subscriber) + db.flush() + + # Add to the Leases table + Lease = dSIPLeases(Gateway.gwid, Subscriber.id, int(ttl)) + + if type == "ip": + # TODO: address entries should include port user specified + Addr = Address(name, auth_ip, 32, settings.FLT_PBX, gwgroup=2200) + db.add(Addr) + db.flush() - # Add the Gateways table - Gateway = Gateways(name, host_addr, strip, prefix, settings.FLT_PBX) - db.add(Gateway) - db.flush() + # Add to the Leases table + Lease = dSIPLeases(gwid=0, sid=0, ttl=int(ttl), addrid=Addr.id) - # Add the Subscribers table - Subscriber = Subscribers(auth_username, auth_password, auth_domain, Gateway.gwid, email) - db.add(Subscriber) - db.flush() + - # Add to the Leases table - Lease = dSIPLeases(Gateway.gwid, Subscriber.id, int(ttl)) db.add(Lease) db.flush() lease_data['leaseid'] = Lease.id - lease_data['username'] = auth_username - lease_data['password'] = auth_password + if type == "userpwd": + lease_data['username'] = auth_username + lease_data['password'] = auth_password + elif type == "ip": + lease_data['allowed_ip'] = auth_ip + lease_data['domain'] = auth_domain lease_data['ttl'] = ttl @@ -198,11 +239,11 @@ def getEndpointLease(): #if not addTaggedCronjob("lease_management", "* * * * *", cron_cmd): # raise Exception('Crontab entry could not be created') - getSharedMemoryDict(STATE_SHMEM_NAME)['kam_reload_required'] = True + getSharedMemoryDict(STATE_SHMEM_NAME)['kam_reload_required'] = False return createApiResponse( msg='Lease created', data=[lease_data], - kamreload=True, + kamreload=False, ) except Exception as ex: From 27231db1f302cc67b1317ed1c1ce06d674ed157b Mon Sep 17 00:00:00 2001 From: Mack Hendricks Date: Tue, 20 Aug 2024 12:16:08 +0000 Subject: [PATCH 2/5] Added logic that will send requests from Slack back in JSON format --- gui/modules/api/api_routes.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gui/modules/api/api_routes.py b/gui/modules/api/api_routes.py index cc520bbe..4b7de875 100644 --- a/gui/modules/api/api_routes.py +++ b/gui/modules/api/api_routes.py @@ -157,6 +157,8 @@ def getEndpointLease(): # Set the ttl to 5 minutes if no ttl from slack is sent else: ttl = "5m" + #Set content type equal to application/json + request.ContentType = 'application/json' else: ttl = request.args.get('ttl') if ttl is None: From 1c75ddd61c127c420734d38046068fece405601e Mon Sep 17 00:00:00 2001 From: Mack Hendricks Date: Wed, 21 Aug 2024 03:38:01 +0000 Subject: [PATCH 3/5] Added logic to parse parameters that are sent over via the Slack command line --- gui/modules/api/api_routes.py | 78 ++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/gui/modules/api/api_routes.py b/gui/modules/api/api_routes.py index 4b7de875..c40cde4e 100644 --- a/gui/modules/api/api_routes.py +++ b/gui/modules/api/api_routes.py @@ -21,7 +21,7 @@ from util.ipc import STATE_SHMEM_NAME, getSharedMemoryDict from modules.api.api_functions import createApiResponse, showApiError, api_security from modules.api.kamailio.functions import reloadKamailio -from util.networking import getExternalIP, hostToIP, safeUriToHost, safeStripPort +from util.networking import getExternalIP, hostToIP, safeUriToHost, safeStripPort, isValidIP from util.notifications import sendEmail from util.security import AES_CTR, urandomChars, KeyCertPair from util.file_handling import change_owner @@ -145,37 +145,61 @@ def getEndpointLease(): db = startSession() - email = request.args.get('email') - if not email: - raise Exception("email parameter is missing") - # Grab ttl parameter from Slack + # Grab the form parameters from Slack request + + # Set up some defaults + ttl = "5m" + type = "userpwd" + auth_ip = None + if request.headers.get('X-Slack-Signature'): - text = request.args.get('text', None) - if text is not None: - ttl = text - # Set the ttl to 5 minutes if no ttl from slack is sent - else: - ttl = "5m" + text = request.form.get('text', None) + if text is not None and len(text) > 0: + print("**** {}".format(text)) + # Split text up + x=text.split(" ") + for req in x: + if "m" in req: + ttl = req + if "userpwd" == req or "ip" == req: + type = req + if "/" in req: + ip,subnet=req.split("/") + if isValidIP(ip): + auth_ip = req + + if type == "ip" and auth_ip is None: + raise http_exceptions.BadRequest("auth_ip must be provided if the type is ip") + elif type == "userpwd" and auth_ip is not None: + raise http_exceptions.BadRequest("auth_ip is not valid when type is userpwd") + + #Grab email address + email = request.form.get('email', None) #Set content type equal to application/json request.ContentType = 'application/json' + + # Grab request parameters from everywhere else else: ttl = request.args.get('ttl') if ttl is None: raise http_exceptions.BadRequest("time to live (ttl) parameter is missing") - - type = request.args.get('type', None) - if type is None: - type = "userpwd" - elif type != "userpwd" and type != "ip": - raise http_exceptions.BadRequest("type can only have a value of ip or userpwd") + email = request.args.get('email') + if not email: + raise Exception("email parameter is missing") - auth_ip = request.args.get('auth_ip', None) - if type == "ip" and auth_ip is None: - raise http_exceptions.BadRequest("auth_ip must be provided if the type is ip") - elif type == "userpwd" and auth_ip is not None: - raise http_exceptions.BadRequest("auth_ip is not valid when type is userpwd") + type = request.args.get('type', None) + if type is None: + type = "userpwd" + elif type != "userpwd" and type != "ip": + raise http_exceptions.BadRequest("type can only have a value of ip or userpwd") + + auth_ip = request.args.get('auth_ip', None) + if type == "ip" and auth_ip is None: + raise http_exceptions.BadRequest("auth_ip must be provided if the type is ip") + elif type == "userpwd" and auth_ip is not None: + raise http_exceptions.BadRequest("auth_ip is not valid when type is userpwd") # Convert TTL to Seconds @@ -211,8 +235,14 @@ def getEndpointLease(): Lease = dSIPLeases(Gateway.gwid, Subscriber.id, int(ttl)) if type == "ip": - # TODO: address entries should include port user specified - Addr = Address(name, auth_ip, 32, settings.FLT_PBX, gwgroup=2200) + # Check for Subnet + if "/" in auth_ip: + ip,subnet = auth_ip.split("/",1) + else: + ip = auth_ip + # Define a default subnet address of 32 + subnet = 32 + Addr = Address(name, ip, subnet, settings.FLT_PBX, gwgroup=2200) db.add(Addr) db.flush() From d4004e049c2e354e340608d6ecb7602e052db9a1 Mon Sep 17 00:00:00 2001 From: Mack Hendricks Date: Sat, 24 Aug 2024 19:43:23 +0000 Subject: [PATCH 4/5] Added support for Slack Signing Secret --- gui/modules/api/api_functions.py | 9 ++++++--- gui/settings.py | 4 ++++ gui/util/security.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/gui/modules/api/api_functions.py b/gui/modules/api/api_functions.py index dee12700..54482c40 100644 --- a/gui/modules/api/api_functions.py +++ b/gui/modules/api/api_functions.py @@ -11,7 +11,7 @@ from werkzeug import exceptions as http_exceptions from shared import debugException, StatusCodes from util.ipc import STATE_SHMEM_NAME, getSharedMemoryDict -from util.security import APIToken +from util.security import APIToken, validSlackSignature from modules.api.licensemanager.functions import getLicenseStatus import settings @@ -113,8 +113,11 @@ def wrapper(*args, **kwargs): # Check if request is from Slack and the endpoint is the leasing endpoint if request.headers.get('X-Slack-Signature'): #flask_rule[:17] == "/api/v1/lease/endpoint": - # checks succeeded allow the request - return func(*args, **kwargs) + if validSlackSignature(settings.SLACK_SIGNING_SECRET,request): + # checks succeeded allow the request + return func(*args, **kwargs) + else: + return jsonify(msg='Unauthorized: Slack signing key is missing or invalid') # Check if token is valid if not apiToken.isValid(): return createApiResponse( diff --git a/gui/settings.py b/gui/settings.py index dc81ee3a..6a09842e 100644 --- a/gui/settings.py +++ b/gui/settings.py @@ -232,4 +232,8 @@ # example for ldap module: # AUTH_MODULES = {"ldap": {"LDAP_HOST":"ldap://ldap.dopensource.com", "USER_SEARCH_BASE":"ou=People,dc=dopensource,dc=com", "GROUP_SEARCH_BASE":"dc=dopensource,dc=com", "GROUP_MEMBER_ATTRIBUTE":"memberUid", "REQUIRED_GROUP":"support", "USER_ATTRIBUTE":"uid"}} AUTH_MODULES = {} + +# slack settings +SLACK_SIGNING_SECRET = '' ############### End Local-Only Settings ################## + diff --git a/gui/util/security.py b/gui/util/security.py index 6bd2664e..94a4254d 100644 --- a/gui/util/security.py +++ b/gui/util/security.py @@ -4,7 +4,8 @@ if sys.path[0] != '/etc/dsiprouter/gui': sys.path.insert(0, '/etc/dsiprouter/gui') -import os, hashlib, binascii, string, ssl, OpenSSL, secrets, re +import os, hashlib, binascii, string, ssl, OpenSSL, secrets, re, hmac +import urllib.parse from Crypto.Cipher import AES from Crypto.Random import get_random_bytes from shared import updateConfig, StatusCodes @@ -482,3 +483,32 @@ def dumpPkey(self, encoding=OpenSSL.crypto.FILETYPE_PEM): def dumpCerts(self, encoding=OpenSSL.crypto.FILETYPE_PEM): return b'\n'.join([OpenSSL.crypto.dump_certificate(encoding, cert) for cert in self.certs]) + +def validSlackSignature(slack_signing_secret,request): + + timestamp = request.headers['X-Slack-Request-Timestamp'] + slack_payload = request.form + dict_slack = slack_payload.to_dict() + + payload= "&".join(['='.join([key, urllib.parse.quote(val, safe='')]) for key, val in dict_slack.items()]) + + ### compose the message: + sig_basestring = 'v0:' + timestamp + ':' + payload + + sig_basestring = sig_basestring.encode('utf-8') + + ## secret + signing_secret = slack_signing_secret.encode('utf-8') # I had an env variable declared with slack_signing_secret + + my_signature = 'v0=' + hmac.new( + signing_secret, + sig_basestring, + hashlib.sha256 + ).hexdigest() + + slack_signature = request.headers['x-slack-signature'] + if hmac.compare_digest(my_signature, slack_signature): + return True + else: + return False + From 19d18e2bedc74898e6f16fd56a6c888b4a531796 Mon Sep 17 00:00:00 2001 From: Mack Hendricks Date: Mon, 26 Aug 2024 07:11:37 -0400 Subject: [PATCH 5/5] Update use-cases.rst Added documentation for using the lease API --- docs/source/user/use-cases.rst | 40 +++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/source/user/use-cases.rst b/docs/source/user/use-cases.rst index bbfdaf4a..5c5555c8 100644 --- a/docs/source/user/use-cases.rst +++ b/docs/source/user/use-cases.rst @@ -496,7 +496,41 @@ If not testing, obtain a valid STIR/SHAKEN certificate and place them in the /et 11. Click Save -The STIR/SHAKEN page should look like this: +Configure Dynamic SIP Credentials (SUBSCRIPTION REQUIRED) +--------------------------------------------------------- -.. image:: images/stir_shaken_settings.png - :align: center +dSIPRouter enables an organization to provide users and systems with SIP credentials that will expire after a time-to-live (ttl). The goal is to minimize the attack surface and user error that would cause SIP credentials to become compromised. The steps to configure is below: + +1. Login to dSIPRouter +2. Purchase a core subscription license from the `dSIPRouter Marketplace `_ +3. Click System Settings -> License Manager +4. Add the license to the system +5. Get or set the API Token. You can set the API token using the command below: + +.. code-block:: bash + + DSIP_HOSTNAME= + DSIP_TOKEN= + dsiprouter setcredentials -ac $DSIP_TOKEN + +6. Invoke the Lease API, which can be found in the API section of the `dSIPRouter Postman `_. There are two types of SIP Credentials supported, user/pass credentials and IP based. + +User/Pass Credential + +.. code-block:: bash + + curl -k -H "Authorization: Bearer $DSIP_TOKEN" -H "Content-Type: application/json" -X GET "https://$DSIP_HOSTNAME:5000/api/v1/lease/endpoint?ttl=15&email=mack@dsiprouter.org" + +IP Based Credential + +.. code-block:: bash + + curl -k -H "Authorization: Bearer $DSIP_TOKEN" -H "Content-Type: application/json" -X GET "https://$DSIP_HOSTNAME:5000/api/v1/lease/endpoint?email=mack@goflyball.com&ttl=15m&type=ip&auth_ip=172.145.24.2" + +7. You can revoke the lease id that was returned when the lease was created: + +.. code-block:: bash + + curl -k -H "Authorization: Bearer $DSIP_TOKEN" -H "Content-Type: application/json" -X DELETE "https://$DSIP_HOSTNAME:5000/api/v1/lease/endpoint/</revoke" + +8. (Optional) You can set the SIP Domain used for generating the User/Pass credentials by changing the DEFAULT_AUTH_DOMAIN parameter in /etc/dsiprouter/gui/settings.py