Browse Source

Finish documentation work for 2018.8.1

* Added a delete method for the user_api
* Added a password strength verification
* Allow the registration of a user to include a desired password
* Raised validation errors instead of value errors
* Added a 404 error handler to return a json APIMessage alongside the 404
merge-requests/1/head
Drew Short 6 years ago
parent
commit
9b91964fb9
  1. 1
      server/Pipfile
  2. 11
      server/Pipfile.lock
  3. 6
      server/README.md
  4. 4
      server/atheneum/__init__.py
  5. 6
      server/atheneum/api/authentication_api.py
  6. 25
      server/atheneum/api/model.py
  7. 36
      server/atheneum/api/user_api.py
  8. 11
      server/atheneum/errors.py
  9. 26
      server/atheneum/service/authentication_service.py
  10. 10
      server/atheneum/service/patch_service.py
  11. 11
      server/atheneum/service/user_service.py
  12. 4
      server/atheneum/utility/json_utility.py
  13. 98
      server/documentation/api/authentication.rst
  14. 9
      server/documentation/api/index.rst
  15. 158
      server/documentation/api/user.rst
  16. 1
      server/documentation/conf.py
  17. 4
      server/documentation/index.rst
  18. 4
      server/documentation/introduction.rst
  19. 2
      server/manage.py
  20. 1
      server/run_tests.sh
  21. 2
      server/tests/api/test_authentication_api.py
  22. 44
      server/tests/api/test_user_api.py
  23. 2
      server/tests/conftest.py
  24. 9
      server/tests/service/test_patch_service.py

1
server/Pipfile

@ -23,6 +23,7 @@ pylint = ">=2.0,<2.1"
pydocstyle = ">=2.1,<2.2"
sphinx = ">=1.7,<1.8"
sphinx-rtd-theme = ">=0.4,<0.5"
sphinxcontrib-httpdomain = ">=1.7,<1.8"
[requires]
python_version = "3.6"

11
server/Pipfile.lock

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "d7f09008c17b9be991ced36644c754f92109844febb8a84df3d2823b6b57618e"
"sha256": "2d44286a15539f26e69e12737247e8f490fa6c5e1f35a28f5c322cbd525d5eda"
},
"pipfile-spec": 6,
"requires": {
@ -547,6 +547,14 @@
"index": "pypi",
"version": "==0.4.1"
},
"sphinxcontrib-httpdomain": {
"hashes": [
"sha256:1fb5375007d70bf180cdd1c79e741082be7aa2d37ba99efe561e1c2e3f38191e",
"sha256:ac40b4fba58c76b073b03931c7b8ead611066a6aebccafb34dc19694f4eb6335"
],
"index": "pypi",
"version": "==1.7.0"
},
"sphinxcontrib-websupport": {
"hashes": [
"sha256:68ca7ff70785cbe1e7bccc71a48b5b6d965d79ca50629606c7861a21b206d9dd",
@ -580,7 +588,6 @@
"sha256:edb04bdd45bfd76c8292c4d9654568efaedf76fe78eb246dde69bdb13b2dad87",
"sha256:f19f2a4f547505fe9072e15f6f4ae714af51b5a681a97f187971f50c283193b6"
],
"markers": "python_version < '3.7' and implementation_name == 'cpython'",
"version": "==1.1.0"
},
"urllib3": {

6
server/README.md

@ -8,8 +8,8 @@
## Installation
```bash
git clone <repository>
cd <cloned repository>
git clone https://gitlab.com/WarrickSothr/Atheneum.git
cd Atheneum/server
pipenv install
pipenv shell
```
@ -28,6 +28,8 @@ docker run -d atheneum:local-test
### Local Development Version
```bash
FLASK_APP=atheneum:atheneum flask db upgrade
python manage.py user register-admin
FLASK_APP=atheneum:atheneum flask run
```

4
server/atheneum/__init__.py

@ -6,7 +6,8 @@ from flask import Flask
from flask_migrate import Migrate
from atheneum.db import db
from atheneum.errors import BaseError, handle_atheneum_base_error
from atheneum.errors import BaseError, handle_atheneum_base_error, \
handle_atheneum_404_error
from atheneum.utility import json_utility, session_utility
dictConfig({
@ -92,6 +93,7 @@ def register_error_handlers(app: Flask) -> None:
:param app:
:return:
"""
app.register_error_handler(404, handle_atheneum_404_error)
app.register_error_handler(BaseError, handle_atheneum_base_error)

6
server/atheneum/api/authentication_api.py

@ -2,7 +2,7 @@
from flask import Blueprint, g
from atheneum.api.decorators import return_json
from atheneum.api.model import APIResponse
from atheneum.api.model import APIMessage, APIResponse
from atheneum.middleware import authentication_middleware
from atheneum.service import (
user_token_service,
@ -37,7 +37,7 @@ def login_bump() -> APIResponse:
:return: A time stamp for the bumped login
"""
user_service.update_last_login_time(g.user)
return APIResponse({'lastLoginTime': g.user.last_login_time}, 200)
return APIResponse(g.user, 200, ['lastLoginTime'])
@AUTH_BLUEPRINT.route('/logout', methods=['POST'])
@ -50,4 +50,4 @@ def logout() -> APIResponse:
:return:
"""
authentication_service.logout(g.user_token)
return APIResponse(None, 200)
return APIResponse(APIMessage(True, None), 200)

25
server/atheneum/api/model.py

@ -1,5 +1,5 @@
"""Model definitions for the api module."""
from typing import Any, List, Optional
from typing import Any, List, Optional, Dict
class APIResponse: # pylint: disable=too-few-public-methods
@ -13,3 +13,26 @@ class APIResponse: # pylint: disable=too-few-public-methods
self.payload = payload
self.status = status
self.options = options
class APIMessage: # pylint: disable=too-few-public-methods
"""Simple class to encapsulate response messages."""
success: bool
message: Optional[str]
def __init__(self,
success: bool,
message: Optional[str]) -> None:
"""Construct an APIMessage."""
self.success = success
self.message = message
def to_dict(self) -> dict:
"""Serialize an APIMessage to a dict."""
obj: Dict[str, Any] = {
'success': self.success
}
if self.message is not None:
obj['message'] = self.message
return obj

36
server/atheneum/api/user_api.py

@ -2,7 +2,7 @@
from flask import Blueprint, abort, request, g
from atheneum.api.decorators import return_json
from atheneum.api.model import APIResponse
from atheneum.api.model import APIResponse, APIMessage
from atheneum.middleware import authentication_middleware
from atheneum.model import User
from atheneum.service import (
@ -47,12 +47,9 @@ def patch_user(name: str) -> APIResponse:
if user is not None:
user_patch: User = transformation_service.deserialize_model(
User, request.json)
try:
patched_user = patch_service.patch(
g.user, user, user_patch, get_patch_fields(request.json))
return APIResponse(patched_user, 200)
except ValueError:
return abort(400)
patched_user = patch_service.patch(
g.user, user, user_patch, get_patch_fields(request.json))
return APIResponse(patched_user, 200)
return abort(404)
@ -68,9 +65,32 @@ def register_user() -> APIResponse:
"""
new_user: User = transformation_service.deserialize_model(
User, request.json)
requested_password = None
if 'password' in request.json:
requested_password = request.json['password'].strip()
registered_user = user_service.register(
name=new_user.name,
password=None,
password=requested_password,
role=new_user.role
)
return APIResponse(payload=registered_user, status=200)
@USER_BLUEPRINT.route('/<name>', methods=['DELETE'])
@return_json
@authentication_middleware.require_token_auth
@authentication_middleware.require_role(required_role=Role.ADMIN)
def delete_user(name: str) -> APIResponse:
"""
Delete a user with the service.
:return: The newly registered User
"""
user = user_service.find_by_name(name)
if user is not None:
user_service.delete(user)
return APIResponse(
APIMessage(True, 'Successfully Deleted'), status=200)
return abort(404)

11
server/atheneum/errors.py

@ -1,8 +1,10 @@
"""Error definitions for Atheneum."""
from typing import Dict
from werkzeug.exceptions import HTTPException
from atheneum.api.decorators import return_json
from atheneum.api.model import APIResponse
from atheneum.api.model import APIResponse, APIMessage
class BaseError(Exception):
@ -44,6 +46,13 @@ class ValidationError(ClientError):
pass
@return_json
def handle_atheneum_404_error(exception: HTTPException) -> APIResponse:
"""Error handler for 404 Atheneum errors."""
return APIResponse(
payload=APIMessage(False, 'Not Found'), status=exception.code)
@return_json
def handle_atheneum_base_error(error: BaseError) -> APIResponse:
"""Error handler for basic Atheneum raised errors."""

26
server/atheneum/service/authentication_service.py

@ -1,14 +1,40 @@
"""Service to handle authentication."""
import re
from datetime import datetime
from typing import Optional
from nacl import pwhash
from nacl.exceptions import InvalidkeyError
from atheneum import errors
from atheneum.model import User, UserToken
from atheneum.service import user_token_service
def validate_password_strength(proposed_password: str) -> str:
"""Validate that a password meets minimum strength requirements."""
# calculating the length
length_error = len(proposed_password) < 8
# searching for digits
digit_error = re.search(r"\d", proposed_password) is None
# searching for uppercase
uppercase_error = re.search(r"[A-Z]", proposed_password) is None
# searching for lowercase
lowercase_error = re.search(r"[a-z]", proposed_password) is None
if length_error or digit_error or uppercase_error or lowercase_error:
raise errors.ValidationError(
' '.join(['The password must be at least 8 characters long.',
'Contain one or more digits,',
'one or more uppercase characters,',
'and one or more lowercase characters']))
return proposed_password
def is_valid_password(user: User, password: str) -> bool:
"""
User password must pass pwhash verify.

10
server/atheneum/service/patch_service.py

@ -2,6 +2,7 @@
from typing import Type, Set, Optional, Any, Dict
from atheneum import db
from atheneum import errors
from atheneum.model import User
from atheneum.service import transformation_service
from atheneum.service import validation_service
@ -39,7 +40,7 @@ def perform_patch(request_user: User,
setattr(original_model, attribute, value)
db.session.commit()
else:
raise ValueError(
raise errors.ValidationError(
'Restricted attributes modified. Invalid Patch Set.')
return original_model
@ -73,7 +74,8 @@ def versioning_aware_patch(request_user: User,
patch_model,
model_attributes,
patched_fields)
raise ValueError('Versions do not match. Concurrent edit in progress.')
raise errors.ValidationError(
'Versions do not match. Concurrent edit in progress.')
def patch(
@ -94,7 +96,7 @@ def patch(
model_attributes = validation_service.get_changable_attribute_names(
original_model)
if patch_model.id is not None and original_model.id != patch_model.id:
raise ValueError('Cannot change ids through patching')
raise errors.ValidationError('Cannot change ids through patching')
if 'version' in model_attributes:
return versioning_aware_patch(
request_user,
@ -108,7 +110,7 @@ def patch(
patch_model,
model_attributes,
patched_fields)
raise ValueError(
raise errors.ValidationError(
'Model types "{}" and "{}" do not match'.format(
original_model.__class__.__name__,
patch_model.__class__.__name__

11
server/atheneum/service/user_service.py

@ -11,6 +11,7 @@ from atheneum import errors
from atheneum.db import db
from atheneum.model import User
from atheneum.service import role_service
from atheneum.service.authentication_service import validate_password_strength
from atheneum.service.transformation_service import (
BaseTransformer,
register_transformer
@ -157,17 +158,25 @@ def find_by_name(name: str) -> Optional[User]:
return User.query.filter_by(name=name).first()
def register(name: str, password: Optional[str], role: Optional[str]) -> User:
def register(
name: str,
password: Optional[str],
role: Optional[str],
validate_password: bool = True) -> User:
"""
Register a new user.
:param name: Desired user name. Must be unique and not already registered
:param password: Password to be hashed and stored for the user
:param role: Role to assign the user [ROLE_USER, ROLE_ADMIN]
:param validate_password: Perform password validation
:return:
"""
if validate_password and password is not None:
validate_password_strength(password)
password = password if password is not None else ''.join(
random.choices(string.ascii_letters + string.digits, k=32))
role = role if role is not None else User.ROLE_USER
if find_by_name(name=name) is not None:

4
server/atheneum/utility/json_utility.py

@ -5,7 +5,7 @@ from typing import Any
import rfc3339
from flask.json import JSONEncoder
from atheneum.api.model import APIResponse
from atheneum.api.model import APIResponse, APIMessage
from atheneum.db import db
from atheneum.errors import BaseError
from atheneum.service.transformation_service import serialize_model
@ -25,6 +25,8 @@ class CustomJSONEncoder(JSONEncoder):
return serialize_model(o.payload, o.options)
if isinstance(payload, BaseError):
return payload.to_dict()
if isinstance(payload, APIMessage):
return payload.to_dict()
return payload
if isinstance(o, db.Model):
return serialize_model(o)

98
server/documentation/api/authentication.rst

@ -0,0 +1,98 @@
Authentication API
==================
.. http:post:: /auth/login
Authenticate with the server and receive a userToken for requests.
**Example request**:
.. sourcecode:: http
POST /auth/login HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Basic <Base64 Encoded Basic Auth>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T11:59:29-05:00",
"enabled": true,
"token": "b94cf5c7-cddc-4610-9d4c-6b8e04088ae8",
"version": 0
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged in
:statuscode 401: authorization failed
.. http:post:: /auth/bump
Bump user login information.
**Example request**:
.. sourcecode:: http
POST /auth/bump HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"lastLoginTime": "2018-07-29T12:15:51-05:00"
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user last_login_time successfully bumped
:statuscode 401: authorization failed
.. http:post:: /auth/logout
Logout a user and remove the provided userToken from valid tokens.
**Example request**:
.. sourcecode:: http
POST /auth/logout HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"success": true
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged out
:statuscode 401: authorization failed

9
server/documentation/api/index.rst

@ -0,0 +1,9 @@
Atheneum API documentation
==========================
.. toctree::
:maxdepth: 2
:caption: Contents:
authentication
user

158
server/documentation/api/user.rst

@ -0,0 +1,158 @@
User API
========
.. http:get:: /user/(str:user_name)
Find a user by name.
**Example request**:
.. sourcecode:: http
GET /user/atheneum_administrator HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T11:58:17-05:00",
"lastLoginTime": "2018-07-29T12:43:27-05:00",
"name": "atheneum_administrator",
"role": "ADMIN",
"version": 0
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged in
:statuscode 401: authorization failed
:statuscode 404: user doesn't exist
.. http:patch:: /user/(str:user_name)
Patch a user.
**Example request**:
.. sourcecode:: http
PATCH /user/atheneum_administrator HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
Content-Type: application/json
{
"lastLoginTime": "2019-07-29T12:43:27-05:00",
"version": 0
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T11:58:17-05:00",
"lastLoginTime": "2019-07-29T12:43:27-05:00",
"name": "atheneum_administrator",
"role": "ADMIN",
"version": 1
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:reqheader Content-Type: application/json
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged in
:statuscode 400: an issue in the payload was discovered
:statuscode 401: authorization failed
:statuscode 404: user doesn't exist
.. http:post:: /user/
Register a new user with the service.
**Example request**:
.. sourcecode:: http
POST /user/ HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
Content-Type: application/json
{
"name": "test_user",
"password": "JvZ9bm79",
"role": "USER"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"creationTime": "2018-07-29T14:16:48-05:00",
"name": "test_user",
"role": "USER",
"version": 0
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:reqheader Content-Type: application/json
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged in
:statuscode 400: an issue in the payload was discovered
:statuscode 401: authorization failed
.. http:delete:: /user/(str:user_name)
Register a new user with the service.
**Example request**:
.. sourcecode:: http
DELETE /user/test_user HTTP/1.1
Host: example.tld
Accept: application/json
Authorization: Token <Base64(user:userToken)>
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"message": "Successfully Deleted",
"success": true
}
:reqheader Accept: the response content type depends on :mailheader:`Accept` header
:reqheader Authorization: The encoded basic authorization
:resheader Content-Type: this depends on :mailheader:`Accept` header of request
:statuscode 200: user successfully logged in
:statuscode 401: authorization failed
:statuscode 404: user doesn't exist

1
server/documentation/conf.py

@ -39,6 +39,7 @@ release = '2018.8.1'
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinxcontrib.httpdomain'
]
# Add any paths that contain templates here, relative to this directory.

4
server/documentation/index.rst

@ -10,11 +10,11 @@ Welcome to Atheneum's documentation!
:maxdepth: 2
:caption: Contents:
introduction
api/index
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

4
server/documentation/introduction.rst

@ -0,0 +1,4 @@
Introduction To Atheneum
========================
TODO

2
server/manage.py

@ -56,7 +56,7 @@ def register_user(
if existing_user is None:
user_password = password if password else ''.join(
random.choices(string.ascii_letters + string.digits, k=24))
new_user = user_service.register(name, user_password, role)
new_user = user_service.register(name, user_password, role, False)
logging.warning(
'Created new user: \'%s\' with password \'%s\' and role %s',
new_user.name,

1
server/run_tests.sh

@ -6,7 +6,6 @@ set -x
python3 --version
python3 -m pip --version
python3 -m pipenv --version
pylint --version
mypy --version

2
server/tests/api/test_authentication_api.py

@ -19,4 +19,4 @@ def test_logout_happy_path(auth: AuthActions):
auth.login()
result = auth.logout()
assert result.status_code == 200
assert result.json is None
assert result.json['success']

44
server/tests/api/test_user_api.py

@ -64,6 +64,25 @@ def test_register_user_happy_path(auth: AuthActions, client: FlaskClient):
assert result.json['name'] == 'test_registered_user'
def test_register_user_invalid_password(
auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result = client.post(
'/user/',
data=json.dumps({
'name': 'test_registered_user',
'password': ''
}),
headers={
auth_header[0]: auth_header[1],
'Content-Type': 'application/json'
})
assert 400 == result.status_code
assert result.json is not None
assert 'message' in result.json
def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
@ -91,3 +110,28 @@ def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient):
assert 400 == result2.status_code
assert result2.json is not None
assert result2.json['message'] == 'User name is already taken.'
def test_delete_user_happy_path(auth: AuthActions, client: FlaskClient):
auth.login()
auth_header = auth.get_authorization_header_token()
result1 = client.post(
'/user/',
data=json.dumps({
'name': 'test_registered_user'
}),
headers={
auth_header[0]: auth_header[1],
'Content-Type': 'application/json'
})
result2 = client.delete(
'/user/'+result1.json['name'],
headers={
auth_header[0]: auth_header[1]
})
assert 200 == result1.status_code
assert result1.json is not None
assert result1.json['name'] == 'test_registered_user'
assert 200 == result2.status_code
assert result2.json is not None
assert 'message' in result2.json

2
server/tests/conftest.py

@ -21,7 +21,7 @@ def add_test_user() -> Tuple[str, str]:
random.choices(string.ascii_letters + string.digits, k=17)).strip()
test_password = ''.join(
random.choices(string.ascii_letters + string.digits, k=32)).strip()
user_service.register(test_username, test_password, User.ROLE_ADMIN)
user_service.register(test_username, test_password, User.ROLE_ADMIN, False)
return test_username, test_password

9
server/tests/service/test_patch_service.py

@ -3,6 +3,7 @@ from datetime import datetime, timedelta
import pytest
from mock import MagicMock, patch
from atheneum import errors
from atheneum.model import UserToken, User
from atheneum.service import patch_service, role_service
@ -38,7 +39,7 @@ def test_patch_of_different_types():
user = User()
user_token = UserToken()
with pytest.raises(ValueError) as error_info:
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user, user_token)
@ -52,7 +53,7 @@ def test_patch_different_ids():
user2 = User()
user2.id = 2
with pytest.raises(ValueError) as error_info:
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user1, user2)
@ -66,7 +67,7 @@ def test_patch_different_versions():
user2 = User()
user2.version = 2
with pytest.raises(ValueError) as error_info:
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user1, user2)
@ -82,5 +83,5 @@ def test_patch_restricted_attributes():
user2.version = 1
user2.name = 'Chris'
with pytest.raises(ValueError) as error_info:
with pytest.raises(errors.ValidationError) as error_info:
patch_service.patch(request_user, user1, user2)
Loading…
Cancel
Save