test_httpauth.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. # Copyright (c) Twisted Matrix Laboratories.
  2. # See LICENSE for details.
  3. """
  4. Tests for L{twisted.web._auth}.
  5. """
  6. from __future__ import division, absolute_import
  7. import base64
  8. from zope.interface import implementer
  9. from zope.interface.verify import verifyObject
  10. from twisted.trial import unittest
  11. from twisted.python.failure import Failure
  12. from twisted.internet.error import ConnectionDone
  13. from twisted.internet.address import IPv4Address
  14. from twisted.cred import error, portal
  15. from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse
  16. from twisted.cred.checkers import ANONYMOUS, AllowAnonymousAccess
  17. from twisted.cred.credentials import IUsernamePassword
  18. from twisted.web.iweb import ICredentialFactory
  19. from twisted.web.resource import IResource, Resource, getChildForRequest
  20. from twisted.web._auth import basic, digest
  21. from twisted.web._auth.wrapper import HTTPAuthSessionWrapper, UnauthorizedResource
  22. from twisted.web._auth.basic import BasicCredentialFactory
  23. from twisted.web.server import NOT_DONE_YET
  24. from twisted.web.static import Data
  25. from twisted.web.test.test_web import DummyRequest
  26. def b64encode(s):
  27. return base64.b64encode(s).strip()
  28. class BasicAuthTestsMixin:
  29. """
  30. L{TestCase} mixin class which defines a number of tests for
  31. L{basic.BasicCredentialFactory}. Because this mixin defines C{setUp}, it
  32. must be inherited before L{TestCase}.
  33. """
  34. def setUp(self):
  35. self.request = self.makeRequest()
  36. self.realm = b'foo'
  37. self.username = b'dreid'
  38. self.password = b'S3CuR1Ty'
  39. self.credentialFactory = basic.BasicCredentialFactory(self.realm)
  40. def makeRequest(self, method=b'GET', clientAddress=None):
  41. """
  42. Create a request object to be passed to
  43. L{basic.BasicCredentialFactory.decode} along with a response value.
  44. Override this in a subclass.
  45. """
  46. raise NotImplementedError("%r did not implement makeRequest" % (
  47. self.__class__,))
  48. def test_interface(self):
  49. """
  50. L{BasicCredentialFactory} implements L{ICredentialFactory}.
  51. """
  52. self.assertTrue(
  53. verifyObject(ICredentialFactory, self.credentialFactory))
  54. def test_usernamePassword(self):
  55. """
  56. L{basic.BasicCredentialFactory.decode} turns a base64-encoded response
  57. into a L{UsernamePassword} object with a password which reflects the
  58. one which was encoded in the response.
  59. """
  60. response = b64encode(b''.join([self.username, b':', self.password]))
  61. creds = self.credentialFactory.decode(response, self.request)
  62. self.assertTrue(IUsernamePassword.providedBy(creds))
  63. self.assertTrue(creds.checkPassword(self.password))
  64. self.assertFalse(creds.checkPassword(self.password + b'wrong'))
  65. def test_incorrectPadding(self):
  66. """
  67. L{basic.BasicCredentialFactory.decode} decodes a base64-encoded
  68. response with incorrect padding.
  69. """
  70. response = b64encode(b''.join([self.username, b':', self.password]))
  71. response = response.strip(b'=')
  72. creds = self.credentialFactory.decode(response, self.request)
  73. self.assertTrue(verifyObject(IUsernamePassword, creds))
  74. self.assertTrue(creds.checkPassword(self.password))
  75. def test_invalidEncoding(self):
  76. """
  77. L{basic.BasicCredentialFactory.decode} raises L{LoginFailed} if passed
  78. a response which is not base64-encoded.
  79. """
  80. response = b'x' # one byte cannot be valid base64 text
  81. self.assertRaises(
  82. error.LoginFailed,
  83. self.credentialFactory.decode, response, self.makeRequest())
  84. def test_invalidCredentials(self):
  85. """
  86. L{basic.BasicCredentialFactory.decode} raises L{LoginFailed} when
  87. passed a response which is not valid base64-encoded text.
  88. """
  89. response = b64encode(b'123abc+/')
  90. self.assertRaises(
  91. error.LoginFailed,
  92. self.credentialFactory.decode,
  93. response, self.makeRequest())
  94. class RequestMixin:
  95. def makeRequest(self, method=b'GET', clientAddress=None):
  96. """
  97. Create a L{DummyRequest} (change me to create a
  98. L{twisted.web.http.Request} instead).
  99. """
  100. request = DummyRequest(b'/')
  101. request.method = method
  102. request.client = clientAddress
  103. return request
  104. class BasicAuthTests(RequestMixin, BasicAuthTestsMixin, unittest.TestCase):
  105. """
  106. Basic authentication tests which use L{twisted.web.http.Request}.
  107. """
  108. class DigestAuthTests(RequestMixin, unittest.TestCase):
  109. """
  110. Digest authentication tests which use L{twisted.web.http.Request}.
  111. """
  112. def setUp(self):
  113. """
  114. Create a DigestCredentialFactory for testing
  115. """
  116. self.realm = b"test realm"
  117. self.algorithm = b"md5"
  118. self.credentialFactory = digest.DigestCredentialFactory(
  119. self.algorithm, self.realm)
  120. self.request = self.makeRequest()
  121. def test_decode(self):
  122. """
  123. L{digest.DigestCredentialFactory.decode} calls the C{decode} method on
  124. L{twisted.cred.digest.DigestCredentialFactory} with the HTTP method and
  125. host of the request.
  126. """
  127. host = b'169.254.0.1'
  128. method = b'GET'
  129. done = [False]
  130. response = object()
  131. def check(_response, _method, _host):
  132. self.assertEqual(response, _response)
  133. self.assertEqual(method, _method)
  134. self.assertEqual(host, _host)
  135. done[0] = True
  136. self.patch(self.credentialFactory.digest, 'decode', check)
  137. req = self.makeRequest(method, IPv4Address('TCP', host, 81))
  138. self.credentialFactory.decode(response, req)
  139. self.assertTrue(done[0])
  140. def test_interface(self):
  141. """
  142. L{DigestCredentialFactory} implements L{ICredentialFactory}.
  143. """
  144. self.assertTrue(
  145. verifyObject(ICredentialFactory, self.credentialFactory))
  146. def test_getChallenge(self):
  147. """
  148. The challenge issued by L{DigestCredentialFactory.getChallenge} must
  149. include C{'qop'}, C{'realm'}, C{'algorithm'}, C{'nonce'}, and
  150. C{'opaque'} keys. The values for the C{'realm'} and C{'algorithm'}
  151. keys must match the values supplied to the factory's initializer.
  152. None of the values may have newlines in them.
  153. """
  154. challenge = self.credentialFactory.getChallenge(self.request)
  155. self.assertEqual(challenge['qop'], b'auth')
  156. self.assertEqual(challenge['realm'], b'test realm')
  157. self.assertEqual(challenge['algorithm'], b'md5')
  158. self.assertIn('nonce', challenge)
  159. self.assertIn('opaque', challenge)
  160. for v in challenge.values():
  161. self.assertNotIn(b'\n', v)
  162. def test_getChallengeWithoutClientIP(self):
  163. """
  164. L{DigestCredentialFactory.getChallenge} can issue a challenge even if
  165. the L{Request} it is passed returns L{None} from C{getClientIP}.
  166. """
  167. request = self.makeRequest(b'GET', None)
  168. challenge = self.credentialFactory.getChallenge(request)
  169. self.assertEqual(challenge['qop'], b'auth')
  170. self.assertEqual(challenge['realm'], b'test realm')
  171. self.assertEqual(challenge['algorithm'], b'md5')
  172. self.assertIn('nonce', challenge)
  173. self.assertIn('opaque', challenge)
  174. class UnauthorizedResourceTests(unittest.TestCase):
  175. """
  176. Tests for L{UnauthorizedResource}.
  177. """
  178. def test_getChildWithDefault(self):
  179. """
  180. An L{UnauthorizedResource} is every child of itself.
  181. """
  182. resource = UnauthorizedResource([])
  183. self.assertIdentical(
  184. resource.getChildWithDefault("foo", None), resource)
  185. self.assertIdentical(
  186. resource.getChildWithDefault("bar", None), resource)
  187. def _unauthorizedRenderTest(self, request):
  188. """
  189. Render L{UnauthorizedResource} for the given request object and verify
  190. that the response code is I{Unauthorized} and that a I{WWW-Authenticate}
  191. header is set in the response containing a challenge.
  192. """
  193. resource = UnauthorizedResource([
  194. BasicCredentialFactory('example.com')])
  195. request.render(resource)
  196. self.assertEqual(request.responseCode, 401)
  197. self.assertEqual(
  198. request.responseHeaders.getRawHeaders(b'www-authenticate'),
  199. [b'basic realm="example.com"'])
  200. def test_render(self):
  201. """
  202. L{UnauthorizedResource} renders with a 401 response code and a
  203. I{WWW-Authenticate} header and puts a simple unauthorized message
  204. into the response body.
  205. """
  206. request = DummyRequest([b''])
  207. self._unauthorizedRenderTest(request)
  208. self.assertEqual(b'Unauthorized', b''.join(request.written))
  209. def test_renderHEAD(self):
  210. """
  211. The rendering behavior of L{UnauthorizedResource} for a I{HEAD} request
  212. is like its handling of a I{GET} request, but no response body is
  213. written.
  214. """
  215. request = DummyRequest([b''])
  216. request.method = b'HEAD'
  217. self._unauthorizedRenderTest(request)
  218. self.assertEqual(b'', b''.join(request.written))
  219. def test_renderQuotesRealm(self):
  220. """
  221. The realm value included in the I{WWW-Authenticate} header set in
  222. the response when L{UnauthorizedResounrce} is rendered has quotes
  223. and backslashes escaped.
  224. """
  225. resource = UnauthorizedResource([
  226. BasicCredentialFactory('example\\"foo')])
  227. request = DummyRequest([b''])
  228. request.render(resource)
  229. self.assertEqual(
  230. request.responseHeaders.getRawHeaders(b'www-authenticate'),
  231. [b'basic realm="example\\\\\\"foo"'])
  232. implementer(portal.IRealm)
  233. class Realm(object):
  234. """
  235. A simple L{IRealm} implementation which gives out L{WebAvatar} for any
  236. avatarId.
  237. @type loggedIn: C{int}
  238. @ivar loggedIn: The number of times C{requestAvatar} has been invoked for
  239. L{IResource}.
  240. @type loggedOut: C{int}
  241. @ivar loggedOut: The number of times the logout callback has been invoked.
  242. """
  243. def __init__(self, avatarFactory):
  244. self.loggedOut = 0
  245. self.loggedIn = 0
  246. self.avatarFactory = avatarFactory
  247. def requestAvatar(self, avatarId, mind, *interfaces):
  248. if IResource in interfaces:
  249. self.loggedIn += 1
  250. return IResource, self.avatarFactory(avatarId), self.logout
  251. raise NotImplementedError()
  252. def logout(self):
  253. self.loggedOut += 1
  254. class HTTPAuthHeaderTests(unittest.TestCase):
  255. """
  256. Tests for L{HTTPAuthSessionWrapper}.
  257. """
  258. makeRequest = DummyRequest
  259. def setUp(self):
  260. """
  261. Create a realm, portal, and L{HTTPAuthSessionWrapper} to use in the tests.
  262. """
  263. self.username = b'foo bar'
  264. self.password = b'bar baz'
  265. self.avatarContent = b"contents of the avatar resource itself"
  266. self.childName = b"foo-child"
  267. self.childContent = b"contents of the foo child of the avatar"
  268. self.checker = InMemoryUsernamePasswordDatabaseDontUse()
  269. self.checker.addUser(self.username, self.password)
  270. self.avatar = Data(self.avatarContent, 'text/plain')
  271. self.avatar.putChild(
  272. self.childName, Data(self.childContent, 'text/plain'))
  273. self.avatars = {self.username: self.avatar}
  274. self.realm = Realm(self.avatars.get)
  275. self.portal = portal.Portal(self.realm, [self.checker])
  276. self.credentialFactories = []
  277. self.wrapper = HTTPAuthSessionWrapper(
  278. self.portal, self.credentialFactories)
  279. def _authorizedBasicLogin(self, request):
  280. """
  281. Add an I{basic authorization} header to the given request and then
  282. dispatch it, starting from C{self.wrapper} and returning the resulting
  283. L{IResource}.
  284. """
  285. authorization = b64encode(self.username + b':' + self.password)
  286. request.requestHeaders.addRawHeader(b'authorization',
  287. b'Basic ' + authorization)
  288. return getChildForRequest(self.wrapper, request)
  289. def test_getChildWithDefault(self):
  290. """
  291. Resource traversal which encounters an L{HTTPAuthSessionWrapper}
  292. results in an L{UnauthorizedResource} instance when the request does
  293. not have the required I{Authorization} headers.
  294. """
  295. request = self.makeRequest([self.childName])
  296. child = getChildForRequest(self.wrapper, request)
  297. d = request.notifyFinish()
  298. def cbFinished(result):
  299. self.assertEqual(request.responseCode, 401)
  300. d.addCallback(cbFinished)
  301. request.render(child)
  302. return d
  303. def _invalidAuthorizationTest(self, response):
  304. """
  305. Create a request with the given value as the value of an
  306. I{Authorization} header and perform resource traversal with it,
  307. starting at C{self.wrapper}. Assert that the result is a 401 response
  308. code. Return a L{Deferred} which fires when this is all done.
  309. """
  310. self.credentialFactories.append(BasicCredentialFactory('example.com'))
  311. request = self.makeRequest([self.childName])
  312. request.requestHeaders.addRawHeader(b'authorization', response)
  313. child = getChildForRequest(self.wrapper, request)
  314. d = request.notifyFinish()
  315. def cbFinished(result):
  316. self.assertEqual(request.responseCode, 401)
  317. d.addCallback(cbFinished)
  318. request.render(child)
  319. return d
  320. def test_getChildWithDefaultUnauthorizedUser(self):
  321. """
  322. Resource traversal which enouncters an L{HTTPAuthSessionWrapper}
  323. results in an L{UnauthorizedResource} when the request has an
  324. I{Authorization} header with a user which does not exist.
  325. """
  326. return self._invalidAuthorizationTest(
  327. b'Basic ' + b64encode(b'foo:bar'))
  328. def test_getChildWithDefaultUnauthorizedPassword(self):
  329. """
  330. Resource traversal which enouncters an L{HTTPAuthSessionWrapper}
  331. results in an L{UnauthorizedResource} when the request has an
  332. I{Authorization} header with a user which exists and the wrong
  333. password.
  334. """
  335. return self._invalidAuthorizationTest(
  336. b'Basic ' + b64encode(self.username + b':bar'))
  337. def test_getChildWithDefaultUnrecognizedScheme(self):
  338. """
  339. Resource traversal which enouncters an L{HTTPAuthSessionWrapper}
  340. results in an L{UnauthorizedResource} when the request has an
  341. I{Authorization} header with an unrecognized scheme.
  342. """
  343. return self._invalidAuthorizationTest(b'Quux foo bar baz')
  344. def test_getChildWithDefaultAuthorized(self):
  345. """
  346. Resource traversal which encounters an L{HTTPAuthSessionWrapper}
  347. results in an L{IResource} which renders the L{IResource} avatar
  348. retrieved from the portal when the request has a valid I{Authorization}
  349. header.
  350. """
  351. self.credentialFactories.append(BasicCredentialFactory('example.com'))
  352. request = self.makeRequest([self.childName])
  353. child = self._authorizedBasicLogin(request)
  354. d = request.notifyFinish()
  355. def cbFinished(ignored):
  356. self.assertEqual(request.written, [self.childContent])
  357. d.addCallback(cbFinished)
  358. request.render(child)
  359. return d
  360. def test_renderAuthorized(self):
  361. """
  362. Resource traversal which terminates at an L{HTTPAuthSessionWrapper}
  363. and includes correct authentication headers results in the
  364. L{IResource} avatar (not one of its children) retrieved from the
  365. portal being rendered.
  366. """
  367. self.credentialFactories.append(BasicCredentialFactory('example.com'))
  368. # Request it exactly, not any of its children.
  369. request = self.makeRequest([])
  370. child = self._authorizedBasicLogin(request)
  371. d = request.notifyFinish()
  372. def cbFinished(ignored):
  373. self.assertEqual(request.written, [self.avatarContent])
  374. d.addCallback(cbFinished)
  375. request.render(child)
  376. return d
  377. def test_getChallengeCalledWithRequest(self):
  378. """
  379. When L{HTTPAuthSessionWrapper} finds an L{ICredentialFactory} to issue
  380. a challenge, it calls the C{getChallenge} method with the request as an
  381. argument.
  382. """
  383. @implementer(ICredentialFactory)
  384. class DumbCredentialFactory(object):
  385. scheme = b'dumb'
  386. def __init__(self):
  387. self.requests = []
  388. def getChallenge(self, request):
  389. self.requests.append(request)
  390. return {}
  391. factory = DumbCredentialFactory()
  392. self.credentialFactories.append(factory)
  393. request = self.makeRequest([self.childName])
  394. child = getChildForRequest(self.wrapper, request)
  395. d = request.notifyFinish()
  396. def cbFinished(ignored):
  397. self.assertEqual(factory.requests, [request])
  398. d.addCallback(cbFinished)
  399. request.render(child)
  400. return d
  401. def _logoutTest(self):
  402. """
  403. Issue a request for an authentication-protected resource using valid
  404. credentials and then return the C{DummyRequest} instance which was
  405. used.
  406. This is a helper for tests about the behavior of the logout
  407. callback.
  408. """
  409. self.credentialFactories.append(BasicCredentialFactory('example.com'))
  410. class SlowerResource(Resource):
  411. def render(self, request):
  412. return NOT_DONE_YET
  413. self.avatar.putChild(self.childName, SlowerResource())
  414. request = self.makeRequest([self.childName])
  415. child = self._authorizedBasicLogin(request)
  416. request.render(child)
  417. self.assertEqual(self.realm.loggedOut, 0)
  418. return request
  419. def test_logout(self):
  420. """
  421. The realm's logout callback is invoked after the resource is rendered.
  422. """
  423. request = self._logoutTest()
  424. request.finish()
  425. self.assertEqual(self.realm.loggedOut, 1)
  426. def test_logoutOnError(self):
  427. """
  428. The realm's logout callback is also invoked if there is an error
  429. generating the response (for example, if the client disconnects
  430. early).
  431. """
  432. request = self._logoutTest()
  433. request.processingFailed(
  434. Failure(ConnectionDone("Simulated disconnect")))
  435. self.assertEqual(self.realm.loggedOut, 1)
  436. def test_decodeRaises(self):
  437. """
  438. Resource traversal which enouncters an L{HTTPAuthSessionWrapper}
  439. results in an L{UnauthorizedResource} when the request has a I{Basic
  440. Authorization} header which cannot be decoded using base64.
  441. """
  442. self.credentialFactories.append(BasicCredentialFactory('example.com'))
  443. request = self.makeRequest([self.childName])
  444. request.requestHeaders.addRawHeader(b'authorization', b'Basic decode should fail')
  445. child = getChildForRequest(self.wrapper, request)
  446. self.assertIsInstance(child, UnauthorizedResource)
  447. def test_selectParseResponse(self):
  448. """
  449. L{HTTPAuthSessionWrapper._selectParseHeader} returns a two-tuple giving
  450. the L{ICredentialFactory} to use to parse the header and a string
  451. containing the portion of the header which remains to be parsed.
  452. """
  453. basicAuthorization = b'Basic abcdef123456'
  454. self.assertEqual(
  455. self.wrapper._selectParseHeader(basicAuthorization),
  456. (None, None))
  457. factory = BasicCredentialFactory('example.com')
  458. self.credentialFactories.append(factory)
  459. self.assertEqual(
  460. self.wrapper._selectParseHeader(basicAuthorization),
  461. (factory, b'abcdef123456'))
  462. def test_unexpectedDecodeError(self):
  463. """
  464. Any unexpected exception raised by the credential factory's C{decode}
  465. method results in a 500 response code and causes the exception to be
  466. logged.
  467. """
  468. class UnexpectedException(Exception):
  469. pass
  470. class BadFactory(object):
  471. scheme = b'bad'
  472. def getChallenge(self, client):
  473. return {}
  474. def decode(self, response, request):
  475. raise UnexpectedException()
  476. self.credentialFactories.append(BadFactory())
  477. request = self.makeRequest([self.childName])
  478. request.requestHeaders.addRawHeader(b'authorization', b'Bad abc')
  479. child = getChildForRequest(self.wrapper, request)
  480. request.render(child)
  481. self.assertEqual(request.responseCode, 500)
  482. self.assertEqual(len(self.flushLoggedErrors(UnexpectedException)), 1)
  483. def test_unexpectedLoginError(self):
  484. """
  485. Any unexpected failure from L{Portal.login} results in a 500 response
  486. code and causes the failure to be logged.
  487. """
  488. class UnexpectedException(Exception):
  489. pass
  490. class BrokenChecker(object):
  491. credentialInterfaces = (IUsernamePassword,)
  492. def requestAvatarId(self, credentials):
  493. raise UnexpectedException()
  494. self.portal.registerChecker(BrokenChecker())
  495. self.credentialFactories.append(BasicCredentialFactory('example.com'))
  496. request = self.makeRequest([self.childName])
  497. child = self._authorizedBasicLogin(request)
  498. request.render(child)
  499. self.assertEqual(request.responseCode, 500)
  500. self.assertEqual(len(self.flushLoggedErrors(UnexpectedException)), 1)
  501. def test_anonymousAccess(self):
  502. """
  503. Anonymous requests are allowed if a L{Portal} has an anonymous checker
  504. registered.
  505. """
  506. unprotectedContents = b"contents of the unprotected child resource"
  507. self.avatars[ANONYMOUS] = Resource()
  508. self.avatars[ANONYMOUS].putChild(
  509. self.childName, Data(unprotectedContents, 'text/plain'))
  510. self.portal.registerChecker(AllowAnonymousAccess())
  511. self.credentialFactories.append(BasicCredentialFactory('example.com'))
  512. request = self.makeRequest([self.childName])
  513. child = getChildForRequest(self.wrapper, request)
  514. d = request.notifyFinish()
  515. def cbFinished(ignored):
  516. self.assertEqual(request.written, [unprotectedContents])
  517. d.addCallback(cbFinished)
  518. request.render(child)
  519. return d