Browse Source

Major quality of life changes

* Refactored code and modules for consistency/readability
* Updated primary migration script
* Impletmented rudimentary User and UserToken services
* Started work on adding testing
* Broke API into blueprints, starting with authentication
* Added a simple cli manage.py utility
* Added a Dockerfile to create a runnable Atheneum instance
merge-requests/1/head
Drew Short 7 years ago
parent
commit
695c2456de
  1. 4
      .dockerignore
  2. 5
      .gitignore
  3. 23
      Dockerfile
  4. 3
      server/Pipfile
  5. 84
      server/Pipfile.lock
  6. 61
      server/atheneum/__init__.py
  7. 71
      server/atheneum/api.py
  8. 1
      server/atheneum/api/__init__.py
  9. 45
      server/atheneum/api/authentication_api.py
  10. 25
      server/atheneum/api/decorators.py
  11. 6
      server/atheneum/api/model.py
  12. 73
      server/atheneum/authentication.py
  13. 2
      server/atheneum/default_settings.py
  14. 2
      server/atheneum/model/__init__.py
  15. 15
      server/atheneum/model/user.py
  16. 42
      server/atheneum/model/user_model.py
  17. 0
      server/atheneum/service/__init__.py
  18. 74
      server/atheneum/service/authentication_service.py
  19. 49
      server/atheneum/service/user_service.py
  20. 10
      server/entrypoint.sh
  21. 124
      server/manage.py
  22. 38
      server/migrations/versions/7160f2b96a1c_.py
  23. 56
      server/migrations/versions/96442b147e22_.py

4
.dockerignore

@ -0,0 +1,4 @@
server/instance/
server/setup.py
server/test/
.admin_credentials

5
.gitignore

@ -0,0 +1,5 @@
instance/
.idea
.admin_credentials
*__pycache__/
.pytest_cache/

23
Dockerfile

@ -0,0 +1,23 @@
FROM python:3.6-slim-stretch
MAINTAINER Drew Short <warrick@sothr.com>
ENV ATHENEUM_APP_DIRECTORY /opt/atheneum
ENV ATHENEUM_CONFIG_DIRECTORY /srv/atheneum/config
ENV ATHENEUM_DATA_DIRECTORY /srv/atheneum/data
RUN mkdir -p ${ATHENEUM_APP_DIRECTORY} \
&& mkdir -p ${ATHENEUM_CONFIG_DIRECTORY} \
&& mkdir -p ${ATHENEUM_DATA_DIRECTORY} \
&& pip install pipenv gunicorn
VOLUME ${ATHENEUM_CONFIG_DIRECTORY}
VOLUME ${ATHENEUM_DATA_DIRECTORY}
COPY ./server/ ${ATHENEUM_APP_DIRECTORY}/
RUN cd ${ATHENEUM_APP_DIRECTORY} \
&& pipenv install --system --deploy --ignore-pipfile
WORKDIR ${ATHENEUM_APP_DIRECTORY}
CMD ./entrypoint.sh

3
server/Pipfile

@ -8,9 +8,12 @@ flask = ">=1.0,<1.1"
flask-sqlalchemy = ">=2.3,<2.4"
flask-migrate = ">=2.1,<2.2"
pynacl = ">=1.2,<1.3"
click = "*"
"rfc3339" = "*"
[dev-packages]
python-dotenv = "*"
pytest = "*"
[requires]
python_version = "3.6"

84
server/Pipfile.lock

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "ea0b054cc713e78e3aef06fe53568b66bebd6a78f353b3a118547f7c765ce745"
"sha256": "1216a9077e9ccca2fea0ea5770f3fe3bfbbe6f6341d88b1aa754f21ee4a54792"
},
"pipfile-spec": 6,
"requires": {
@ -18,9 +18,9 @@
"default": {
"alembic": {
"hashes": [
"sha256:85bd3ea7633024e4930900bc64fb58f9742dedbc6ebb6ecf25be2ea9a3c1b32e"
"sha256:1cd32df9a3b8c1749082ef60ffbe05ff16617b6afadfdabc680dcb9344af33d7"
],
"version": "==0.9.9"
"version": "==0.9.10"
},
"cffi": {
"hashes": [
@ -28,9 +28,12 @@
"sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef",
"sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50",
"sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f",
"sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30",
"sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93",
"sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257",
"sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b",
"sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3",
"sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e",
"sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc",
"sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04",
"sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6",
@ -39,11 +42,13 @@
"sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b",
"sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd",
"sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95",
"sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5",
"sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e",
"sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6",
"sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca",
"sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31",
"sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1",
"sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2",
"sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085",
"sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801",
"sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4",
@ -59,6 +64,7 @@
"sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
"sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
],
"index": "pypi",
"version": "==6.7"
},
"flask": {
@ -126,19 +132,27 @@
"sha256:1d33e775fab3f383167afb20b9927aaf4961b953d76eeb271a5703a6d756b65b",
"sha256:2a42b2399d0428619e58dac7734838102d35f6dcdee149e0088823629bf99fbb",
"sha256:2dce05ac8b3c37b9e2f65eab56c544885607394753e9613fd159d5e2045c2d98",
"sha256:63cfccdc6217edcaa48369191ae4dca0c390af3c74f23c619e954973035948cd",
"sha256:6453b0dae593163ffc6db6f9c9c1597d35c650598e2c39c0590d1757207a1ac2",
"sha256:73a5a96fb5fbf2215beee2353a128d382dbca83f5341f0d3c750877a236569ef",
"sha256:8abb4ef79161a5f58848b30ab6fb98d8c466da21fdd65558ce1d7afc02c70b5f",
"sha256:8ac1167195b32a8755de06efd5b2d2fe76fc864517dab66aaf65662cc59e1988",
"sha256:8f505f42f659012794414fa57c498404e64db78f1d98dfd40e318c569f3c783b",
"sha256:9c8a06556918ee8e3ab48c65574f318f5a0a4d31437fc135da7ee9d4f9080415",
"sha256:a1e25fc5650cf64f01c9e435033e53a4aca9de30eb9929d099f3bb078e18f8f2",
"sha256:be71cd5fce04061e1f3d39597f93619c80cdd3558a6c9ba99a546f144a8d8101",
"sha256:c5b1a7a680218dee9da0f1b5e24072c46b3c275d35712bc1d505b85bb03441c0",
"sha256:cb785db1a9468841a1265c9215c60fe5d7af2fb1b209e3316a152704607fc582",
"sha256:cf6877124ae6a0698404e169b3ba534542cfbc43f939d46b927d956daf0a373a",
"sha256:d0eb5b2795b7ee2cbcfcadacbe95a13afbda048a262bd369da9904fecb568975",
"sha256:d3a934e2b9f20abac009d5b6951067cfb5486889cb913192b4d8288b216842f1",
"sha256:d795f506bcc9463efb5ebb0f65ed77921dcc9e0a50499dedd89f208445de9ecb",
"sha256:d8aaf7e5d6b0e0ef7d6dbf7abeb75085713d0100b4eb1a4e4e857de76d77ac45",
"sha256:de2aaca8386cf4d70f1796352f2346f48ddb0bed61dc43a3ce773ba12e064031",
"sha256:e0d38fa0a75f65f556fb912f2c6790d1fa29b7dd27a1d9cc5591b281321eaaa9",
"sha256:eb2acabbd487a46b38540a819ef67e477a674481f84a82a7ba2234b9ba46f752",
"sha256:eeee629828d0eb4f6d98ac41e9a3a6461d114d1d0aa111a8931c049359298da0",
"sha256:f5836463a3c0cca300295b229b6c7003c415a9d11f8f9288ddbd728e2746524c",
"sha256:f5ce9e26d25eb0b2d96f3ef0ad70e1d3ae89b5d60255c462252a3e456a48c053",
"sha256:fabf73d5d0286f9e078774f3435601d2735c94ce9e514ac4fb945701edead7e4"
],
@ -158,6 +172,14 @@
],
"version": "==1.0.3"
},
"rfc3339": {
"hashes": [
"sha256:589c8b8cab8a35f85313cb80f1b0b0b3ca16a527f354beadb59882fd4473f187",
"sha256:a8167214d37449a6af9b463285baffc87a6d65e013507fd1ba63a48a60b62043"
],
"index": "pypi",
"version": "==6.0"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
@ -167,9 +189,9 @@
},
"sqlalchemy": {
"hashes": [
"sha256:d6cda03b0187d6ed796ff70e87c9a7dce2c2c9650a7bc3c022cd331416853c31"
"sha256:e21e5561a85dcdf16b8520ae4daec7401c5c24558e0ce004f9b60be75c4b6957"
],
"version": "==1.2.7"
"version": "==1.2.9"
},
"werkzeug": {
"hashes": [
@ -180,6 +202,51 @@
}
},
"develop": {
"atomicwrites": {
"hashes": [
"sha256:240831ea22da9ab882b551b31d4225591e5e447a68c5e188db5b89ca1d487585",
"sha256:a24da68318b08ac9c9c45029f4a10371ab5b20e4226738e150e6e7c571630ae6"
],
"version": "==1.1.5"
},
"attrs": {
"hashes": [
"sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265",
"sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b"
],
"version": "==18.1.0"
},
"more-itertools": {
"hashes": [
"sha256:2b6b9893337bfd9166bee6a62c2b0c9fe7735dcf85948b387ec8cba30e85d8e8",
"sha256:6703844a52d3588f951883005efcf555e49566a48afd4db4e965d69b883980d3",
"sha256:a18d870ef2ffca2b8463c0070ad17b5978056f403fb64e3f15fe62a52db21cc0"
],
"version": "==4.2.0"
},
"pluggy": {
"hashes": [
"sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff",
"sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c",
"sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5"
],
"version": "==0.6.0"
},
"py": {
"hashes": [
"sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7",
"sha256:e31fb2767eb657cbde86c454f02e99cb846d3cd9d61b318525140214fdc0e98e"
],
"version": "==1.5.4"
},
"pytest": {
"hashes": [
"sha256:8ea01fc4fcc8e1b1e305252b4bc80a1528019ab99fd3b88666c9dc38d754406c",
"sha256:90898786b3d0b880b47645bae7b51aa9bbf1e9d1e4510c2cfd15dd65c70ea0cd"
],
"index": "pypi",
"version": "==3.6.2"
},
"python-dotenv": {
"hashes": [
"sha256:4965ed170bf51c347a89820e8050655e9c25db3837db6602e906b6d850fad85c",
@ -187,6 +254,13 @@
],
"index": "pypi",
"version": "==0.8.2"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
}
}
}

61
server/atheneum/__init__.py

@ -1,43 +1,82 @@
import os
from logging.config import dictConfig
from flask import Flask
from flask_migrate import Migrate
from flask_migrate import Migrate, upgrade
from flask_sqlalchemy import SQLAlchemy
from atheneum import utility
db: SQLAlchemy = SQLAlchemy()
from atheneum.api import api_blueprint
dictConfig({
'version': 1,
'formatters': {'default': {
'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
}},
'handlers': {'wsgi': {
'class': 'logging.StreamHandler',
'stream': 'ext://flask.logging.wsgi_errors_stream',
'formatter': 'default'
}},
'root': {
'level': 'INFO',
'handlers': ['wsgi']
}
})
def create_app(test_config=None):
app = Flask(__name__, instance_relative_config=True)
app.logger.debug('Creating Atheneum Server')
data_directory = os.getenv('ATHENEUM_DATA_DIRECTORY', '/tmp')
app.logger.debug('Atheneum Data Directory: %s', data_directory)
app.config.from_mapping(
SECRET_KEY='dev',
SQLALCHEMY_DATABASE_URI='sqlite:////tmp/atheneum-test.db'
# SQLALCHEMY_DATABASE_URI=os.path.join(app.instance_path, 'atheneum.db')
SQLALCHEMY_DATABASE_URI='sqlite:///{}/atheneum.db'
.format(data_directory)
)
if test_config is None:
app.logger.debug('Loading configurations')
app.config.from_object('atheneum.default_settings')
app.config.from_pyfile('config.py', silent=True)
if os.getenv('ATHENEUM_SERVER_SETTINGS', None):
app.config.from_envvar('ATHENEUM_SERVER_SETTINGS')
if os.getenv('ATHENEUM_SETTINGS', None):
app.config.from_envvar('ATHENEUM_SETTINGS')
else:
app.config.from_pyfile(test_config)
app.logger.debug('Loading test configuration')
app.config.from_object(test_config)
try:
os.makedirs(app.instance_path)
except OSError:
pass
app.register_blueprint(api_blueprint)
app.json_encoder = utility.CustomJSONEncoder
app.logger.debug('Initializing Application')
db.init_app(app)
migrate = Migrate(app, db)
app.logger.debug('Registering Database Models')
Migrate(app, db)
return app
def register_blueprints(app):
from atheneum.api import auth_blueprint
app.register_blueprint(auth_blueprint)
app = create_app()
register_blueprints(app)
def init_db():
"""Clear existing data and create new tables."""
upgrade('migrations')
if __name__ == "__main__":
app = create_app()
app.run()
app.run()

71
server/atheneum/api.py

@ -1,71 +0,0 @@
from functools import wraps
from typing import Any, Callable, NamedTuple
from flask import Blueprint, jsonify, Response, session
from atheneum.authentication import (
generate_token,
require_basic_auth,
require_token_auth
)
api_blueprint = Blueprint(name='api', import_name=__name__, url_prefix='/api')
class APIResponse(NamedTuple):
payload: Any
status: int
def return_json(func: Callable) -> Callable:
"""
If an Response object is not returned, jsonify the result and return it
:param func:
:return:
"""
@wraps(func)
def decorate(*args, **kwargs):
result = func(*args, **kwargs)
if isinstance(result, Response):
return result
if isinstance(result, APIResponse):
return jsonify(result.payload), result.status
return jsonify(result)
return decorate
@api_blueprint.route('/login', methods=['POST'])
@return_json
@require_basic_auth
def login() -> APIResponse:
"""
Get a token for continued authentication
:return: A login token for continued authentication
"""
token = generate_token()
return APIResponse({'token': token}, 200)
@api_blueprint.route('/login/bump', methods=['POST'])
@return_json
@require_token_auth
def login_bump() -> APIResponse:
"""
Update the user last seen timestamp
:return: A time stamp for the bumped login
"""
return APIResponse(None, 200)
@api_blueprint.route('/logout', methods=['POST'])
@return_json
@require_token_auth
def logout() -> APIResponse:
"""
logout and delete a token
:return:
"""
session.pop('user')
return APIResponse(None, 200)

1
server/atheneum/api/__init__.py

@ -0,0 +1 @@
from atheneum.api.authentication_api import auth_blueprint

45
server/atheneum/api/authentication_api.py

@ -0,0 +1,45 @@
from flask import Blueprint, g
from atheneum.api.decorators import return_json
from atheneum.api.model import APIResponse
from atheneum.middleware import authentication_middleware
from atheneum.service import user_token_service, authentication_service
auth_blueprint = Blueprint(
name='auth', import_name=__name__, url_prefix='/auth')
@auth_blueprint.route('/login', methods=['POST'])
@return_json
@authentication_middleware.require_basic_auth
def login() -> APIResponse:
"""
Get a token for continued authentication
:return: A login token for continued authentication
"""
user_token = user_token_service.create(g.user)
return APIResponse({'token': user_token.token}, 200)
@auth_blueprint.route('/bump', methods=['POST'])
@return_json
@authentication_middleware.require_token_auth
def login_bump() -> APIResponse:
"""
Update the user last seen timestamp
:return: A time stamp for the bumped login
"""
authentication_service.bump_login(g.user)
return APIResponse(g.user.last_login_time, 200)
@auth_blueprint.route('/logout', methods=['POST'])
@return_json
@authentication_middleware.require_token_auth
def logout() -> APIResponse:
"""
logout and delete a token
:return:
"""
authentication_service.logout(g.user_token)
return APIResponse(None, 200)

25
server/atheneum/api/decorators.py

@ -0,0 +1,25 @@
from functools import wraps
from typing import Callable
from flask import jsonify, Response
from atheneum.api.model import APIResponse
def return_json(func: Callable) -> Callable:
"""
If an Response object is not returned, jsonify the result and return it
:param func:
:return:
"""
@wraps(func)
def decorate(*args, **kwargs):
result = func(*args, **kwargs)
if isinstance(result, Response):
return result
if isinstance(result, APIResponse):
return jsonify(result.payload), result.status
return jsonify(result)
return decorate

6
server/atheneum/api/model.py

@ -0,0 +1,6 @@
from typing import Any, NamedTuple
class APIResponse(NamedTuple):
payload: Any
status: int

73
server/atheneum/authentication.py

@ -1,73 +0,0 @@
import base64
import uuid
from functools import wraps
from typing import Optional, Callable
from flask import request, Response, session
from werkzeug.datastructures import Authorization
from werkzeug.http import bytes_to_wsgi, wsgi_to_bytes
def authenticate_with_password(username: str, password: str) -> bool:
session['user'] = None
return True
def authenticate_with_token(username: str, token: str) -> bool:
session['user'] = None
return True
def authentication_failed(auth_type: str) -> Response:
return Response(
status=401,
headers={
'WWW-Authenticate': '%s realm="Login Required"' % auth_type
})
def parse_token_authorization_header(header_value) -> Optional[Authorization]:
if not header_value:
return
value = wsgi_to_bytes(header_value)
try:
auth_type, auth_info = value.split(None, 1)
auth_type = auth_type.lower()
except ValueError:
return
if auth_type == b'token':
try:
username, token = base64.b64decode(auth_info).split(b':', 1)
except Exception:
return
return Authorization('token', {'username': bytes_to_wsgi(username),
'password': bytes_to_wsgi(token)})
def require_basic_auth(func: Callable) -> Callable:
@wraps(func)
def decorate(*args, **kwargs):
auth = request.authorization
if auth and authenticate_with_password(auth.username, auth.password):
return func(*args, **kwargs)
else:
return authentication_failed('Basic')
return decorate
def require_token_auth(func: Callable) -> Callable:
@wraps(func)
def decorate(*args, **kwargs):
token = parse_token_authorization_header(
request.headers.get('WWW-Authenticate', None))
if token and authenticate_with_token(token.username, token.password):
return func(*args, **kwargs)
else:
return authentication_failed('Token')
return decorate
def generate_token() -> uuid.UUID:
return uuid.uuid4()

2
server/atheneum/default_settings.py

@ -1,4 +1,4 @@
DEBUG = True
DEBUG = False
SECRET_KEY = b'\xb4\x89\x0f\x0f\xe5\x88\x97\xfe\x8d<\x0b@d\xe9\xa5\x87%' \
b'\xc6\xf0@l1\xe3\x90g\xfaA.?u=s' # CHANGE ME IN REAL CONFIG
SQLALCHEMY_TRACK_MODIFICATIONS=False

2
server/atheneum/model/__init__.py

@ -1 +1 @@
from atheneum.model.user import User
from atheneum.model.user_model import User, UserToken

15
server/atheneum/model/user.py

@ -1,15 +0,0 @@
from atheneum import db
class User(db.Model):
__tablename__ = 'user'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(60), unique=True, nullable=False)
password_hash = db.Column('password_hash', db.Unicode(128), nullable=False)
password_dblt = db.Column('password_dblt', db.Unicode(32))
password_revision = db.Column(
'password_revision', db.SmallInteger, default=0, nullable=False)
creation_time = db.Column('creation_time', db.DateTime, nullable=False)
last_login_time = db.Column('last_login_time', db.DateTime)
version = db.Column('version', db.Integer, default=1, nullable=False)

42
server/atheneum/model/user_model.py

@ -0,0 +1,42 @@
from atheneum import db
class User(db.Model):
__tablename__ = 'user'
ROLE_USER = 'USER'
ROLE_ADMIN = 'ADMIN'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Unicode(60), unique=True, nullable=False)
role = db.Column(
'role',
db.Unicode(32),
nullable=False,
default=ROLE_USER, )
password_hash = db.Column('password_hash', db.Unicode(128), nullable=False)
password_revision = db.Column(
'password_revision', db.SmallInteger, default=0, nullable=False)
creation_time = db.Column('creation_time', db.DateTime, nullable=False)
last_login_time = db.Column('last_login_time', db.DateTime)
version = db.Column('version', db.Integer, default=1, nullable=False)
class UserToken(db.Model):
__tablename__ = 'user_token'
user_token_id = db.Column('id', db.Integer, primary_key=True)
user_id = db.Column(
'user_id',
db.Integer,
db.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
index=True)
token = db.Column('token', db.Unicode(36), nullable=False)
note = db.Column('note', db.Unicode(128), nullable=True)
enabled = db.Column('enabled', db.Boolean, nullable=False, default=True)
expiration_time = db.Column('expiration_time', db.DateTime, nullable=True)
creation_time = db.Column('creation_time', db.DateTime, nullable=False)
last_edit_time = db.Column('last_edit_time', db.DateTime)
last_usage_time = db.Column('last_usage_time', db.DateTime)
version = db.Column('version', db.Integer, default=1, nullable=False)

0
server/atheneum/service/__init__.py

74
server/atheneum/service/authentication_service.py

@ -0,0 +1,74 @@
import uuid
from datetime import datetime
from typing import Optional, Tuple
from nacl import pwhash
from nacl.exceptions import InvalidkeyError
from atheneum.model import User, UserToken
from atheneum.service import user_service, user_token_service
def generate_token() -> uuid.UUID:
return uuid.uuid4()
def get_password_hash(password: str) -> Tuple[str, int]:
"""
Retrieve argon2id password hash.
:param password: plaintext password to convert
:return: Tuple[password_hash, password_revision]
"""
return pwhash.argon2id.str(password.encode('utf8')).decode('utf8'), 1
def is_valid_password(user: User, password: str) -> bool:
assert user
try:
return pwhash.verify(
user.password_hash.encode('utf8'), password.encode('utf8'))
except InvalidkeyError:
pass
return False
def is_valid_token(user_token: Optional[UserToken]) -> bool:
"""
Token must be enabled and if it has an expiration, it must be
greater than now.
:param user_token:
:return:
"""
if user_token is None:
return False
if not user_token.enabled:
return False
if (user_token.expiration_time is not None
and user_token.expiration_time < datetime.utcnow()):
return False
return True
def bump_login(user: Optional[User]):
"""
Update the last login time for the user
:param user:
:return:
"""
if user is not None:
user_service.update_last_login_time(user)
def logout(user_token: Optional[UserToken] = None):
"""
Remove a user_token associated with a client session
:param user_token:
:return:
"""
if user_token is not None:
user_token_service.delete(user_token)

49
server/atheneum/service/user_service.py

@ -0,0 +1,49 @@
from datetime import datetime
from typing import Optional
from atheneum import app, db
from atheneum.model import User
from atheneum.service import authentication_service
def register(name: str, password: str, role: str) -> User:
password_hash, password_revision = authentication_service.get_password_hash(
password)
new_user = User(
name=name,
role=role,
password_hash=password_hash,
password_revision=password_revision,
creation_time=datetime.now(),
version=0)
db.session.add(new_user)
db.session.commit()
app.logger.info('Registered new user: %s with role: %s', name, role)
return new_user
def delete(user: User) -> bool:
existing_user = db.session.delete(user)
if existing_user is None:
db.session.commit()
return True
return False
def update_last_login_time(user: User):
user.last_login_time = datetime.now()
db.session.commit()
def update_password(user: User, password: str):
password_hash, password_revision = authentication_service.get_password_hash(
password)
user.password_hash = password_hash
user.password_revision = password_revision
db.session.commit()
def find_by_name(name: str) -> Optional[User]:
return User.query.filter_by(name=name).first()

10
server/entrypoint.sh

@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Migrate the Database
FLASK_APP=atheneum:app flask db upgrade
# Make sure an administrator is registered
python manage.py user register-admin
# Start the application
gunicorn -b 0.0.0.0:8080 atheneum:app

124
server/manage.py

@ -0,0 +1,124 @@
import logging
import random
import string
from typing import Optional
from os import path
import click
from click import Context
from atheneum import app
from atheneum.model import User
from atheneum.service import user_service
logging.basicConfig()
@click.group()
def main():
pass
@click.group(name='user')
def user_command_group():
pass
@click.command(name='delete')
@click.argument('name')
def delete_user(name: str):
logging.info('Deleting user with name \'%s\'', name)
existing_user = User.query.filter_by(name=name).first()
if existing_user is not None:
successful = user_service.delete(existing_user)
if successful:
logging.warning('Deleted user with name \'%s\'', name)
else:
logging.error('Failed to delete user with name \'%s\'', name)
else:
logging.warning('User with name \'%s\' doesn\'t exist', name)
@click.command('register')
@click.argument('name')
@click.argument('password', required=False)
@click.option('--role',
default=User.ROLE_USER,
envvar='ROLE',
help='Role to assign to the user. default=[USER]')
def register_user(
name: str,
role: str,
password: Optional[str] = None):
logging.info('Registering user with name \'%s\'', name)
existing_user = User.query.filter_by(name=name).first()
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)
logging.warning(
'Created new user: \'%s\' with password \'%s\' and role %s',
new_user.name,
user_password,
new_user.role)
else:
logging.warning('User \'%s\' already exists. Did you mean to update?',
name)
@click.command(name='register-admin')
@click.pass_context
def register_admin_user(ctx: Context):
admin_users = User.query.filter_by(role=User.ROLE_ADMIN).all()
if len(admin_users) == 0:
name = 'atheneum_administrator'
password = ''.join(
random.choices(string.ascii_letters + string.digits, k=32))
ctx.invoke(
register_user,
name=name,
role=User.ROLE_ADMIN,
password=password)
admin_credential_file = '.admin_credentials'
with open(admin_credential_file, 'w') as f:
f.write('{}:{}'.format(name, password))
logging.info(
'These credentials can also be retrieved from {}'.format(
path.abspath(admin_credential_file)))
@click.command(name='reset-password')
@click.argument('name')
@click.argument('password', required=False)
def reset_user_password(name: str, password: Optional[str] = None):
logging.info('Resetting user password for \'%s\'', name)
existing_user = User.query.filter_by(name=name).first()
if existing_user is not None:
user_password = password if password else ''.join(
random.choices(string.ascii_letters + string.digits, k=24))
user_service.update_password(existing_user, user_password)
logging.warning(
'Updated user: \'%s\' with password \'%s\'',
name,
user_password)
else:
logging.warning('User with name \'%s\' doesn\'t exist', name)
@click.command(name='list')
def list_users():
all_users = User.query.all()
[click.echo(user.name) for user in all_users]
main.add_command(user_command_group)
user_command_group.add_command(register_user)
user_command_group.add_command(register_admin_user)
user_command_group.add_command(delete_user)
user_command_group.add_command(reset_user_password)
user_command_group.add_command(list_users)
if __name__ == '__main__':
logging.debug('Managing: %s', app.name)
with app.app_context():
main()

38
server/migrations/versions/7160f2b96a1c_.py

@ -1,38 +0,0 @@
"""empty message
Revision ID: 7160f2b96a1c
Revises:
Create Date: 2018-05-15 23:39:48.110843
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '7160f2b96a1c'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.Unicode(length=60), nullable=False),
sa.Column('password_hash', sa.Unicode(length=128), nullable=False),
sa.Column('password_dblt', sa.Unicode(length=32), nullable=True),
sa.Column('password_revision', sa.SmallInteger(), nullable=False),
sa.Column('creation_time', sa.DateTime(), nullable=False),
sa.Column('last_login_time', sa.DateTime(), nullable=True),
sa.Column('version', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user')
# ### end Alembic commands ###

56
server/migrations/versions/96442b147e22_.py

@ -0,0 +1,56 @@
"""Initial User DB Migration
Revision ID: 96442b147e22
Revises:
Create Date: 2018-07-03 14:22:42.833390
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '96442b147e22'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.Unicode(length=60), nullable=False),
sa.Column('role', sa.Unicode(length=32), nullable=False),
sa.Column(
'password_hash',
sa.Unicode(length=128),
nullable=False),
sa.Column(
'password_revision', sa.SmallInteger(), nullable=False),
sa.Column('creation_time', sa.DateTime(), nullable=False),
sa.Column('last_login_time', sa.DateTime(), nullable=True),
sa.Column('version', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'))
op.create_table('user_token',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token', sa.Unicode(length=36), nullable=False),
sa.Column('note', sa.Unicode(length=128), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('expiration_time', sa.DateTime(), nullable=True),
sa.Column('creation_time', sa.DateTime(), nullable=False),
sa.Column('last_edit_time', sa.DateTime(), nullable=True),
sa.Column('last_usage_time', sa.DateTime(), nullable=True),
sa.Column('version', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'))
op.create_index(op.f('ix_user_token_user_id'), 'user_token', ['user_id'],
unique=False)
def downgrade():
op.drop_index(op.f('ix_user_token_user_id'), table_name='user_token')
op.drop_table('user_token')
op.drop_table('user')
Loading…
Cancel
Save