From 4a25573e2d6b821e10b6c3a0e461334bbaa73e33 Mon Sep 17 00:00:00 2001 From: meejah Date: Tue, 26 Jan 2016 23:49:23 -0700 Subject: [PATCH] Add simple auth-token to get JSON data --- src/allmydata/client.py | 26 +++++++++++ src/allmydata/test/test_web.py | 84 +++++++++++++++++++++++++++++++++- src/allmydata/web/common.py | 50 ++++++++++++++++++-- 3 files changed, 156 insertions(+), 4 deletions(-) diff --git a/src/allmydata/client.py b/src/allmydata/client.py index 41840e85c..4d001e00a 100644 --- a/src/allmydata/client.py +++ b/src/allmydata/client.py @@ -1,5 +1,6 @@ import os, stat, time, weakref from allmydata import node +from base64 import urlsafe_b64encode from zope.interface import implements from twisted.internet import reactor, defer @@ -332,6 +333,9 @@ class Client(node.Node, pollmixin.PollMixin): DEP["n"] = int(self.get_config("client", "shares.total", DEP["n"])) DEP["happy"] = int(self.get_config("client", "shares.happy", DEP["happy"])) + # for the CLI to authenticate to local JSON endpoints + self._create_auth_token() + self.init_client_storage_broker() self.history = History(self.stats_provider) self.terminator = Terminator() @@ -341,6 +345,28 @@ class Client(node.Node, pollmixin.PollMixin): self.init_blacklist() self.init_nodemaker() + def get_auth_token(self): + """ + This returns a local authentication token, which is just some + random data in "api_auth_token" which must be echoed to API + calls. + + Currently only the URI '/magic' for magic-folder status; other + endpoints are invited to include this as well, as appropriate. + """ + return self.get_private_config('api_auth_token') + + def _create_auth_token(self): + """ + Creates new auth-token data written to 'private/api_auth_token'. + + This is intentionally re-created every time the node starts. + """ + self.write_private_config( + 'api_auth_token', + urlsafe_b64encode(os.urandom(32)) + '\n', + ) + def init_client_storage_broker(self): # create a StorageFarmBroker object, for use by Uploader/Downloader # (and everybody else who wants to use storage servers) diff --git a/src/allmydata/test/test_web.py b/src/allmydata/test/test_web.py index e5f1e42d0..35e969b77 100644 --- a/src/allmydata/test/test_web.py +++ b/src/allmydata/test/test_web.py @@ -2,17 +2,19 @@ import os.path, re, urllib, time, cgi import simplejson from StringIO import StringIO +from zope.interface import implementer from twisted.application import service from twisted.trial import unittest from twisted.internet import defer, reactor from twisted.internet.task import Clock -from twisted.web import client, error, http +from twisted.web import client, error, http, server from twisted.python import failure, log from foolscap.api import fireEventually, flushEventualQueue from nevow.util import escapeToXML from nevow import rend +from nevow.inevow import IRequest from allmydata import interfaces, uri, webish, dirnode from allmydata.storage.shares import get_share_file @@ -5901,3 +5903,83 @@ class CompletelyUnhandledError(Exception): class ErrorBoom(rend.Page): def beforeRender(self, ctx): raise CompletelyUnhandledError("whoops") + + +@implementer(IRequest) +class FakeRequest(object): + def __init__(self): + self.method = "POST" + self.args = dict() + self.fields = [] + + +class FakeClientWithToken(object): + token = 'a' * 32 + + def get_auth_token(self): + return self.token + + +class TestTokenOnlyApi(unittest.TestCase): + + def setUp(self): + self.client = FakeClientWithToken() + self.page = common.TokenOnlyWebApi(self.client) + + def test_not_post(self): + req = FakeRequest() + req.method = "GET" + + self.assertRaises( + server.UnsupportedMethod, + self.page.renderHTTP, req, + ) + + def test_missing_token(self): + req = FakeRequest() + + exc = self.assertRaises( + common.WebError, + self.page.renderHTTP, req, + ) + self.assertEquals(exc.text, "Missing token") + self.assertEquals(exc.code, 401) + + def test_invalid_token(self): + wrong_token = 'b' * 32 + req = FakeRequest() + req.args['token'] = [wrong_token] + + exc = self.assertRaises( + common.WebError, + self.page.renderHTTP, req, + ) + self.assertEquals(exc.text, "Invalid token") + self.assertEquals(exc.code, 401) + + def test_valid_token_no_t_arg(self): + req = FakeRequest() + req.args['token'] = [self.client.token] + + with self.assertRaises(common.WebError) as exc: + self.page.renderHTTP(req) + self.assertEquals(exc.exception.text, "Must provide 't=' argument") + self.assertEquals(exc.exception.code, 400) + + def test_valid_token_invalid_t_arg(self): + req = FakeRequest() + req.args['token'] = [self.client.token] + req.args['t'] = 'not at all json' + + with self.assertRaises(common.WebError) as exc: + self.page.renderHTTP(req) + self.assertTrue("invalid type" in exc.exception.text) + self.assertEquals(exc.exception.code, 400) + + def test_valid(self): + req = FakeRequest() + req.args['token'] = [self.client.token] + req.args['t'] = ['json'] + + result = self.page.renderHTTP(req) + self.assertTrue(result == NotImplemented) diff --git a/src/allmydata/web/common.py b/src/allmydata/web/common.py index 5cfe84d9a..22f1fcc69 100644 --- a/src/allmydata/web/common.py +++ b/src/allmydata/web/common.py @@ -5,7 +5,7 @@ import simplejson from twisted.web import http, server from twisted.python import log from zope.interface import Interface -from nevow import loaders, appserver +from nevow import loaders, appserver, rend from nevow.inevow import IRequest from nevow.util import resource_filename from allmydata import blacklist @@ -15,6 +15,7 @@ from allmydata.interfaces import ExistingChildError, NoSuchChildError, \ MustBeReadonlyError, MustNotBeUnknownRWError, SDMF_VERSION, MDMF_VERSION from allmydata.mutable.common import UnrecoverableFileError from allmydata.util import abbreviate +from allmydata.util.hashutil import timing_safe_compare from allmydata.util.time_format import format_time, format_delta from allmydata.util.encodingutil import to_str, quote_output @@ -363,9 +364,11 @@ class MyExceptionHandler(appserver.DefaultExceptionHandler): traceback = f.getTraceback() return self.simple(ctx, traceback, http.INTERNAL_SERVER_ERROR) + class NeedOperationHandleError(WebError): pass + class RenderMixin: def renderHTTP(self, ctx): @@ -379,6 +382,47 @@ class RenderMixin: # do the same thing. m = getattr(self, 'render_' + request.method, None) if not m: - from twisted.web.server import UnsupportedMethod - raise UnsupportedMethod(getattr(self, 'allowedMethods', ())) + raise server.UnsupportedMethod(getattr(self, 'allowedMethods', ())) return m(ctx) + + +class TokenOnlyWebApi(rend.Page): + """ + I provide a rend.Page implementation that only accepts POST calls, + and only if they have a 'token=' arg with the correct + authentication token (see + :meth:`allmydata.client.Client.get_auth_token`). Callers must also + provide the "t=" argument to indicate the return-value (the only + valid value for this is "json") + + Subclasses should override '_render_json' which should process the + API call and return a valid JSON object. This will only be called + if the correct token is present and valid (during renderHTTP + processing). + """ + + def __init__(self, client): + super(TokenOnlyWebApi, self).__init__() + self.client = client + + def post_json(self, req): + return NotImplemented + + def renderHTTP(self, ctx): + req = IRequest(ctx) + if req.method != 'POST': + raise server.UnsupportedMethod(('POST',)) + + token = get_arg(req, "token", None) + if not token: + raise WebError("Missing token", http.UNAUTHORIZED) + if not timing_safe_compare(token, self.client.get_auth_token()): + raise WebError("Invalid token", http.UNAUTHORIZED) + + t = get_arg(req, "t", "").strip() + if not t: + raise WebError("Must provide 't=' argument") + if t == u'json': + return self.post_json(req) + else: + raise WebError("'%s' invalid type for 't' arg" % (t,), http.BAD_REQUEST)