Skip to content

Commit cbe951d

Browse files
author
Shakeel Mohamed
committed
Merge branch 'feature/cookie-auth' into bug/namespace-for-server-endpoints
2 parents 59f207b + c7a20c8 commit cbe951d

File tree

5 files changed

+285
-54
lines changed

5 files changed

+285
-54
lines changed

splunklib/binding.py

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import urllib
3232
import io
3333
import sys
34+
import Cookie
3435

3536
from datetime import datetime
3637
from functools import wraps
@@ -67,6 +68,45 @@ def new_f(*args, **kwargs):
6768
return new_f
6869

6970

71+
def _parse_cookies(cookie_str, dictionary):
72+
"""Tries to parse any key-value pairs of cookies in a string,
73+
then updates the the dictionary with any key-value pairs found.
74+
75+
**Example**::
76+
dictionary = {}
77+
_parse_cookies('my=value', dictionary)
78+
# Now the following is True
79+
dictionary['my'] == 'value'
80+
81+
:param cookie_str: A string containing "key=value" pairs from an HTTP "Set-Cookie" header.
82+
:type cookie_str: ``str``
83+
:param dictionary: A dictionary to update with any found key-value pairs.
84+
:type dictionary: ``dict``
85+
"""
86+
parsed_cookie = Cookie.SimpleCookie(cookie_str)
87+
for cookie in parsed_cookie.values():
88+
dictionary[cookie.key] = cookie.coded_value
89+
90+
91+
def _make_cookie_header(cookies):
92+
"""
93+
Takes a list of 2-tuples of key-value pairs of
94+
cookies, and returns a valid HTTP ``Cookie``
95+
header.
96+
97+
**Example**::
98+
99+
header = _make_cookie_header([("key", "value"), ("key_2", "value_2")])
100+
# Now the following is True
101+
header == "key=value; key_2=value_2"
102+
103+
:param cookies: A list of 2-tuples of cookie key-value pairs.
104+
:type cookies: ``list`` of 2-tuples
105+
:return: ``str` An HTTP header cookie string.
106+
:rtype: ``str``
107+
"""
108+
return "; ".join("%s=%s" % (key, value) for key, value in cookies)
109+
70110
# Singleton values to eschew None
71111
class _NoAuthenticationToken(object):
72112
"""The value stored in a :class:`Context` or :class:`splunklib.client.Service`
@@ -225,7 +265,7 @@ def f():
225265
@wraps(request_fun)
226266
def wrapper(self, *args, **kwargs):
227267
if self.token is _NoAuthenticationToken and \
228-
self.cookie is _NoAuthenticationToken:
268+
not self.has_cookies():
229269
# Not yet logged in.
230270
if self.autologin and self.username and self.password:
231271
# This will throw an uncaught
@@ -428,9 +468,27 @@ def __init__(self, handler=None, **kwargs):
428468
self.username = kwargs.get("username", "")
429469
self.password = kwargs.get("password", "")
430470
self.autologin = kwargs.get("autologin", False)
431-
self.cookie = kwargs.get("cookie", _NoAuthenticationToken)
432-
if self.cookie is None: # In case someone explicitly passes cookie=None
433-
self.cookie = _NoAuthenticationToken
471+
472+
# Store any cookies in the self.http._cookies dict
473+
if kwargs.has_key("cookie") and kwargs['cookie'] not in [None, _NoAuthenticationToken]:
474+
_parse_cookies(kwargs["cookie"], self.http._cookies)
475+
476+
def get_cookies(self):
477+
"""Gets the dictionary of cookies from the ``HttpLib`` member of this instance.
478+
479+
:return: Dictionary of cookies stored on the ``self.http``.
480+
:rtype: ``dict``
481+
"""
482+
return self.http._cookies
483+
484+
def has_cookies(self):
485+
"""Returns true if the ``HttpLib` member of this instance has at least
486+
one cookie stored.
487+
488+
:return: ``True`` if there is at least one cookie, else ``False``
489+
:rtype: ``bool``
490+
"""
491+
return len(self.get_cookies()) > 0
434492

435493
# Shared per-context request headers
436494
@property
@@ -443,8 +501,8 @@ def _auth_headers(self):
443501
444502
:returns: A list of 2-tuples containing key and value
445503
"""
446-
if self.cookie is not _NoAuthenticationToken:
447-
return [("cookie", self.cookie)]
504+
if self.has_cookies():
505+
return [("Cookie", _make_cookie_header(self.get_cookies().items()))]
448506
elif self.token is _NoAuthenticationToken:
449507
return []
450508
else:
@@ -761,10 +819,10 @@ def login(self):
761819
c = binding.Context(...).login()
762820
# Then issue requests...
763821
"""
764-
# If self.cookie and self.token only, use the cookie
765-
if self.cookie is not _NoAuthenticationToken and \
822+
823+
if self.has_cookies() and \
766824
(not self.username and not self.password):
767-
# If we were passed a session cookie, but no username or
825+
# If we were passed session cookie(s), but no username or
768826
# password, then login is a nop, since we're automatically
769827
# logged in.
770828
return
@@ -784,12 +842,6 @@ def login(self):
784842
password=self.password,
785843
cookie="1") # In Splunk 6.2+, passing "cookie=1" will return the "set-cookie" header
786844

787-
# Store the cookie
788-
for key, value in response.headers:
789-
if key.lower() == "set-cookie":
790-
self.cookie = value
791-
break
792-
793845
body = response.body.read()
794846
session = XML(body).findtext("./sessionKey")
795847
self.token = "Splunk %s" % session
@@ -801,8 +853,9 @@ def login(self):
801853
raise
802854

803855
def logout(self):
804-
"""Forgets the current session token."""
856+
"""Forgets the current session token, and cookies."""
805857
self.token = _NoAuthenticationToken
858+
self.http._cookies = {}
806859
return self
807860

808861
def _abspath(self, path_segment,
@@ -889,6 +942,9 @@ def connect(**kwargs):
889942
:param token: The current session token (optional). Session tokens can be
890943
shared across multiple service instances.
891944
:type token: ``string``
945+
:param cookie: A session cookie. When provided, you don't need to call :meth:`login`.
946+
This parameter is only supported for Splunk 6.2+.
947+
:type cookie: ``string``
892948
:param username: The Splunk account username, which is used to
893949
authenticate the Splunk instance.
894950
:type username: ``string``
@@ -1030,6 +1086,7 @@ class HttpLib(object):
10301086
"""
10311087
def __init__(self, custom_handler=None):
10321088
self.handler = handler() if custom_handler is None else custom_handler
1089+
self._cookies = {}
10331090

10341091
def delete(self, url, headers=None, **kwargs):
10351092
"""Sends a DELETE request to a URL.
@@ -1141,10 +1198,16 @@ def request(self, url, message, **kwargs):
11411198
raise HTTPError(response)
11421199

11431200
# Update the cookie with any HTTP request
1144-
for key, value in response.headers:
1201+
# Initially, assume list of 2-tuples
1202+
key_value_tuples = response.headers
1203+
# If response.headers is a dict, get the key-value pairs as 2-tuples
1204+
# this is the case when using urllib2
1205+
if isinstance(response.headers, dict):
1206+
key_value_tuples = response.headers.items()
1207+
for key, value in key_value_tuples:
11451208
if key.lower() == "set-cookie":
1146-
self.cookie = value
1147-
break
1209+
_parse_cookies(value, self._cookies)
1210+
11481211
return response
11491212

11501213

@@ -1256,7 +1319,6 @@ def request(url, message, **kwargs):
12561319
"Host": host,
12571320
"User-Agent": "splunk-sdk-python/0.1",
12581321
"Accept": "*/*",
1259-
"Cookie": "1"
12601322
} # defaults
12611323
for key, value in message["headers"]:
12621324
head[key] = value

splunklib/client.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
import socket
6868
import contextlib
6969

70-
from binding import Context, HTTPError, AuthenticationError, namespace, UrlEncoded, _encode, _NoAuthenticationToken
70+
from binding import Context, HTTPError, AuthenticationError, namespace, UrlEncoded, _encode, _make_cookie_header
7171
from data import record
7272
import data
7373

@@ -262,7 +262,6 @@ def _parse_atom_metadata(content):
262262

263263
return record({'access': access, 'fields': fields})
264264

265-
266265
# kwargs: scheme, host, port, app, owner, username, password
267266
def connect(**kwargs):
268267
"""This function connects and logs in to a Splunk instance.
@@ -285,6 +284,9 @@ def connect(**kwargs):
285284
:param `token`: The current session token (optional). Session tokens can be
286285
shared across multiple service instances.
287286
:type token: ``string``
287+
:param cookie: A session cookie. When provided, you don't need to call :meth:`login`.
288+
This parameter is only supported for Splunk 6.2+.
289+
:type cookie: ``string``
288290
:param autologin: When ``True``, automatically tries to log in again if the
289291
session terminates.
290292
:type autologin: ``boolean``
@@ -302,7 +304,9 @@ def connect(**kwargs):
302304
a = s.apps["my_app"]
303305
...
304306
"""
305-
return Service(**kwargs).login()
307+
s = Service(**kwargs)
308+
s.login()
309+
return s
306310

307311

308312
# In preparation for adding Storm support, we added an
@@ -345,6 +349,9 @@ class Service(_BaseService):
345349
:param `token`: The current session token (optional). Session tokens can be
346350
shared across multiple service instances.
347351
:type token: ``string``
352+
:param cookie: A session cookie. When provided, you don't need to call :meth:`login`.
353+
This parameter is only supported for Splunk 6.2+.
354+
:type cookie: ``string``
348355
:param `username`: The Splunk account username, which is used to
349356
authenticate the Splunk instance.
350357
:type username: ``string``
@@ -362,6 +369,8 @@ class Service(_BaseService):
362369
s = client.connect(username="boris", password="natasha")
363370
# Or if you already have a session token
364371
s = client.Service(token="atg232342aa34324a")
372+
# Or if you already have a valid cookie
373+
s = client.Service(cookie="splunkd_8089=...")
365374
"""
366375
def __init__(self, **kwargs):
367376
super(Service, self).__init__(**kwargs)
@@ -1902,9 +1911,9 @@ def attach(self, host=None, source=None, sourcetype=None):
19021911

19031912
cookie_or_auth_header = "Authorization: %s\r\n" % self.service.token
19041913

1905-
# If we have a cookie, use it instead of "Authorization: ..."
1906-
if self.service.cookie is not _NoAuthenticationToken:
1907-
cookie_or_auth_header = "Cookie: %s\r\n" % self.service.cookie
1914+
# If we have cookie(s), use them instead of "Authorization: ..."
1915+
if self.service.has_cookies():
1916+
cookie_or_auth_header = "Cookie: %s\r\n" % _make_cookie_header(self.service.get_cookies().items())
19081917

19091918
# Since we need to stream to the index connection, we have to keep
19101919
# the connection open and use the Splunk extension headers to note

tests/test_binding.py

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import socket
2727
import sys
2828
import ssl
29+
import Cookie
2930

3031
import splunklib.binding as binding
3132
from splunklib.binding import HTTPError, AuthenticationError, UrlEncoded
@@ -476,6 +477,8 @@ def test_logout(self):
476477
response = self.context.get("/services")
477478
self.assertEqual(response.status, 200)
478479
self.context.logout()
480+
self.assertEqual(self.context.token, binding._NoAuthenticationToken)
481+
self.assertEqual(self.context.get_cookies(), {})
479482
self.assertRaises(AuthenticationError,
480483
self.context.get, "/services")
481484
self.assertRaises(AuthenticationError,
@@ -503,26 +506,82 @@ def test_cookie_in_auth_headers(self):
503506
self.assertNotEqual(self.context._auth_headers, [])
504507
self.assertEqual(len(self.context._auth_headers), 1)
505508
self.assertEqual(len(self.context._auth_headers), 1)
506-
self.assertEqual(self.context._auth_headers[0][0], "cookie")
509+
self.assertEqual(self.context._auth_headers[0][0], "Cookie")
507510
self.assertEqual(self.context._auth_headers[0][1][:8], "splunkd_")
508511

509512
def test_got_cookie_on_connect(self):
510-
self.assertIsNotNone(self.context.cookie)
511-
self.assertNotEqual(self.context.cookie, binding._NoAuthenticationToken)
512-
self.assertEqual(self.context.cookie[:8], "splunkd_")
513+
self.assertIsNotNone(self.context.get_cookies())
514+
self.assertNotEqual(self.context.get_cookies(), {})
515+
self.assertEqual(len(self.context.get_cookies()), 1)
516+
self.assertEqual(self.context.get_cookies().keys()[0][:8], "splunkd_")
517+
518+
def test_cookie_with_autologin(self):
519+
self.context.autologin = True
520+
self.assertEqual(self.context.get("/services").status, 200)
521+
self.assertTrue(self.context.has_cookies())
522+
self.context.logout()
523+
self.assertFalse(self.context.has_cookies())
524+
self.assertEqual(self.context.get("/services").status, 200)
525+
self.assertTrue(self.context.has_cookies())
526+
527+
def test_cookie_without_autologin(self):
528+
self.context.autologin = False
529+
self.assertEqual(self.context.get("/services").status, 200)
530+
self.assertTrue(self.context.has_cookies())
531+
self.context.logout()
532+
self.assertFalse(self.context.has_cookies())
533+
self.assertRaises(AuthenticationError,
534+
self.context.get, "/services")
513535

514536
def test_got_updated_cookie_with_get(self):
515-
old_cookie = self.context.cookie
537+
old_cookies = self.context.get_cookies()
516538
resp = self.context.get("apps/local")
517539
found = False
518540
for key, value in resp.headers:
519541
if key.lower() == "set-cookie":
520542
found = True
521543
self.assertEqual(value[:8], "splunkd_")
522-
# It's unlikely that the cookie will change during this short test
523-
self.assertEqual(value, old_cookie)
544+
545+
new_cookies = {}
546+
binding._parse_cookies(value, new_cookies)
547+
# We're only expecting 1 in this scenario
548+
self.assertEqual(len(old_cookies), 1)
549+
self.assertTrue(len(new_cookies.values()), 1)
550+
self.assertEqual(old_cookies, new_cookies)
551+
self.assertEqual(new_cookies.values()[0], old_cookies.values()[0])
524552
self.assertTrue(found)
525553

554+
def test_login_fails_with_bad_cookie(self):
555+
new_context = binding.connect(**{"cookie": "bad=cookie"})
556+
# We should get an error if using a bad cookie
557+
try:
558+
new_context.get("apps/local")
559+
self.fail()
560+
except AuthenticationError as ae:
561+
self.assertEqual(ae.message, "Request failed: Session is not logged in.")
562+
563+
def test_login_with_multiple_cookies(self):
564+
bad_cookie = 'bad=cookie'
565+
new_context = binding.connect(**{"cookie": bad_cookie})
566+
# We should get an error if using a bad cookie
567+
try:
568+
new_context.get("apps/local")
569+
self.fail()
570+
except AuthenticationError as ae:
571+
self.assertEqual(ae.message, "Request failed: Session is not logged in.")
572+
# Bring in a valid cookie now
573+
for key, value in self.context.get_cookies().items():
574+
new_context.get_cookies()[key] = value
575+
576+
self.assertEqual(len(new_context.get_cookies()), 2)
577+
self.assertTrue('bad' in new_context.get_cookies().keys())
578+
self.assertTrue('cookie' in new_context.get_cookies().values())
579+
580+
for k, v in self.context.get_cookies().items():
581+
self.assertEqual(new_context.get_cookies()[k], v)
582+
583+
self.assertEqual(new_context.get("apps/local").status, 200)
584+
526585
def test_login_fails_without_cookie_or_token(self):
527586
opts = {
528587
'host': self.opts.kwargs['host'],

0 commit comments

Comments
 (0)