start python network client; fix auth resolution api responses

pull/4/head
Blake DeMarcy 2017-04-04 04:51:37 -05:00
parent cb611cbd23
commit 9083ed9355
6 changed files with 265 additions and 24 deletions

View File

@ -0,0 +1,206 @@
import urllib.request as url
from hashlib import sha256
import json
class BBJ:
"""
A python implementation to the BBJ api: all of its endpoints are
mapped to native methods, it maps error responses to exceptions, and
it includes helper functions for several common patterns.
It should be noted that endpoints utilizing usermaps are returned as
tuples, where [0] is the value and [1] is the usermap dictionary.
Methods who do this will mention it in their documentation.
You can call them like `threads, usermap = bbj.thread_index()`
__init__ can take a host string and a port value (which can be
either int or str). It defaults to "127.0.0.1" and 8080, expanding
out to http://127.0.0.1:8080/.
Standard library exceptions are used, but several new attributes are
attached to them before raising: .code, .description, and .body.
code and description map the same values returned by the api. body
is the raw error object. Classes are mapped as follows:
0, 1, 2: ChildProcessError
3: ValueError
4: UserWarning
5: ConnectionRefusedError
attributes can be accessed as follows:
try:
response = bbj.endpoint():
except UserWarning as e:
assert e.code == 4
print(e.description)
# want the raw error object? thats weird, but whatever.
return e.body
See the offical API error documentation for more details.
"""
def __init__(self, host="127.0.0.1", port=8080):
self.base = "http://{}:{}/api/%s".format(host, port)
self.user_name = None
self.user_auth = None
self.send_auth = True
def __call__(self, *args, **kwargs):
return self.request(*args, **kwargs)
def request(self, endpoint, **params):
headers = {"Content-Type": "application/json"}
if params.get("no_auth"):
params.pop("no_auth")
elif all([self.send_auth, self.user_name, self.user_auth]):
headers.update({"User": self.user_name, "Auth": self.user_auth})
data = bytes(json.dumps(params), "utf8")
request = url.Request(
self.base % endpoint,
data=data,
headers=headers)
try:
with url.urlopen(request) as _r:
response = _r.read()
except url.HTTPError as e:
response = e.file.read()
value = json.loads(str(response, "utf8"))
if value and value.get("error"):
self.raise_exception(value["error"])
return value
def raise_exception(self, error_object):
"""
Takes an API error object and raises the appropriate exception.
"""
description = error_object["description"]
code = error_object["code"]
if code in [0, 1, 2]:
e = ChildProcessError(description)
elif code == 3:
e = ValueError(description)
elif code == 4:
e = UserWarning(description)
elif code == 5:
e = ConnectionRefusedError(description)
e.code, e.description, e.body = code, description, error_object
raise e
def validate(self, key, value, exception=AssertionError):
"""
Uses the server's db_sanity_check method to verify the validty
of value by key. If it is invalid, kwarg exception (default
AssertionError) is raised with the exception containing the
attribute .description as the server's reason.
"""
response = self(
"db_sanity_check",
no_auth=True,
key=key,
value=value
)
if not response["data"]["bool"]:
description = response["data"]["description"]
error = exception(description)
error.description = description
raise error
return True
def validate_all(self, keys_and_values, exception=AssertionError):
"""
Accepts an iterable of tuples, where in each, [0] is a key and
[1] a value to pass to validate.
"""
for key, value in keys_and_values:
self.validate(key, value, exception)
def set_credentials(self, user_name, user_auth, hash_auth=True, check_validity=True):
"""
Internalizes user_name and user_auth. Unless hash_auth=False is
specified, user_auth is assumed to be an unhashed password
string and it gets hashed with sha256. If you want to handle
hashing yourself, make sure to disable that.
Unless check_validity is set to false, the new credentials are
sent to the server and a ConnectionRefusedError is raised if
they do not match server authentication data. ValueError is
raised if the credentials contain illegal values, or the
specified user is not registered.
On success, True is returned and the values are set.
"""
if hash_auth:
user_auth = sha256(bytes(user_auth, "utf8")).hexdigest()
if check_validity and not self.validate_credentials(user_name, user_auth):
self.user_auth = None
self.user_name = None
raise ConnectionRefusedError("Auth and User do not match")
self.user_auth = user_auth
self.user_name = user_name
return True
def validate_credentials(self, user_name, user_auth):
"""
Pings the server to check that user_name can be authenticated with
user_auth. Raises ConnectionRefusedError if they cannot. Raises
ValueError if the credentials contain illegal values.
"""
self.validate_all([
("user_name", user_name),
("auth_hash", user_auth)
], ValueError)
response = self("check_auth",
no_auth=True,
target_user=user_name,
target_hash=user_auth
)
return response["data"]
def user_is_registered(self, user_name):
"""
Returns True or False whether user_name is registered
into the system.
"""
return self(
"user_is_registered",
no_auth=True,
target_user=user_name
)["data"]
def user_register(self, user_name, user_auth, hash_auth=True):
"""
Register user_name into the system with user_auth. Unless hash_auth
is set to false, user_auth should be a password string.
"""
pass
# return self(
# )
def thread_index(self):
"""
Returns a tuple where [0] is a list of all threads ordered by
most recently interacted, and [1] is a usermap object.
"""
response = self("thread_index")
return response["data"], response["usermap"]

View File

@ -0,0 +1,27 @@
Errors in BBJ are separated into 6 different codes to help
ease handling a little bit. Errors are all or nothing, there
are no "warnings". If a response has a non-false error field,
then data will always be null. An error response from the api
looks like this...
```
{
"error": {
"code": // an integer from 0 to 5,
"description": // a string describing the error in detail.
}
"data": null // ALWAYS null if error is not false
"usermap": {} // ALWAYS empty if error is not false
}
```
The codes split errors into a few categories. Some are oriented
to client developers while others should be shown directly to
users.
* 0: Malformed but non-empty json input. An empty json input where it is required is handled by code 3. This is just decoding errors. The exception text is returned as description.
* 1: Internal server error. A short representation of the internal exception as well as the code the server logged it as is returned in the description. Your clients cannot recover from this class of error, and its probably not your fault if you encounter it. If you ever get one, file a bug report.
* 2: Server HTTP error: This is similar to the above but captures errors for the HTTP server rather than BBJs own codebase. The description contains the HTTP error code and server description. This notably covers 404s and thus invalid endpoint names.
* 3: Parameter error: client sent erroneous input for its method. This could mean missing arguments, type errors, etc. It generalizes errors that should be fixed by the client developer and the returned descriptions are geared to them rather than end users.
* 4: User error: These errors regard actions that the user has taken that are invalid, but not really errors in a traditional sense. The description field should be shown to users verbatim, in a clear and noticeable fashion. They are formatted as concise English sentences and end with appropriate punctuation marks.
* 5: Authorization error: This code represents an erroneous User/Auth header pair. This should trigger the user to provide correct credentials or fall back to anon mode.

View File

@ -9,7 +9,7 @@ create table users (
auth_hash text, -- string (sha256 hash)
quip text, -- string (possibly empty)
bio text, -- string (possibly empty)
color int, -- int (from 0 to 8)
color int, -- int (from 0 to 6)
is_admin int, -- bool
created real -- floating point unix timestamp (when this user registered)
);

View File

@ -44,8 +44,7 @@ def api_method(function):
auth = cherrypy.request.headers.get("Auth")
if (username and not auth) or (auth and not username):
return json.dumps(schema.error(5,
"User or Auth was given without the other."))
raise BBJParameterError("User or Auth was given without the other.")
elif not username and not auth:
user = db.anon
@ -55,9 +54,8 @@ def api_method(function):
if not user:
raise BBJUserError("User %s is not registered" % username)
if auth != user["auth_hash"]:
return json.dumps(schema.error(5,
"Invalid authorization key for user."))
elif auth != user["auth_hash"]:
raise BBJException(5, "Invalid authorization key for user.")
# api_methods may choose to bind a usermap into the thread_data
# which will send it off with the response
@ -342,23 +340,33 @@ class API(object):
test.exposed = True
# user anonymity is achieved in the laziest possible way: a literal user
# named anonymous. may god have mercy on my soul.
_c = sqlite3.connect(dbname)
try:
db.anon = db.user_resolve(_c, "anonymous")
if not db.anon:
db.anon = db.user_register(
_c, "anonymous", # this is the hash for "anon"
"5430eeed859cad61d925097ec4f53246"
"1ccf1ab6b9802b09a313be1478a4d614")
finally:
_c.close()
del _c
def api_http_error(status, message, traceback, version):
return json.dumps(schema.error(2, "HTTP error {}: {}".format(status, message)))
CONFIG = {
"/": {
"error_page.default": api_http_error
}
}
def run():
cherrypy.quickstart(API(), "/api")
# user anonymity is achieved in the laziest possible way: a literal user
# named anonymous. may god have mercy on my soul.
_c = sqlite3.connect(dbname)
try:
db.anon = db.user_resolve(_c, "anonymous")
if not db.anon:
db.anon = db.user_register(
_c, "anonymous", # this is the hash for "anon"
"5430eeed859cad61d925097ec4f53246"
"1ccf1ab6b9802b09a313be1478a4d614")
finally:
_c.close()
del _c
cherrypy.quickstart(API(), "/api", CONFIG)
if __name__ == "__main__":

View File

@ -365,9 +365,9 @@ def validate(keys_and_values):
elif key == "color":
if color in range(0, 9):
if value in range(0, 6):
continue
raise BBJParameterError(
"Color specification out of range (int 0-8)")
"Color specification out of range (int 0-6)")
return True

View File

@ -67,7 +67,7 @@ def user_internal(
auth_hash, # string (sha256 hash)
quip, # string (possibly empty)
bio, # string (possibly empty)
color, # int from 0 to 8
color, # int from 0 to 6
is_admin, # bool (supply as either False/True or 0/1)
created): # floating point unix timestamp (when user registered)
@ -97,7 +97,7 @@ def user_external(
user_name, # string
quip, # string (possibly empty)
bio, # string (possibly empty)
color, # int from 0 to 8
color, # int from 0 to 6
admin, # bool (can be supplied as False/True or 0/1)
created): # floating point unix timestamp (when user registered)