Browse Source

Initial copy from Atheneum project

merge-requests/1/merge
Drew Short 6 years ago
commit
bcbb1750b8
  1. 4
      .dockerignore
  2. 7
      .gitignore
  3. 19
      .gitlab-ci.yml
  4. 23
      Dockerfile
  5. 201
      LICENSE
  6. 1
      server/.env
  7. 23
      server/Pipfile
  8. 368
      server/Pipfile.lock
  9. 83
      server/atheneum/__init__.py
  10. 1
      server/atheneum/api/__init__.py
  11. 45
      server/atheneum/api/authentication_api.py
  12. 25
      server/atheneum/api/decorators.py
  13. 6
      server/atheneum/api/model.py
  14. 4
      server/atheneum/default_settings.py
  15. 0
      server/atheneum/middleware/__init__.py
  16. 87
      server/atheneum/middleware/authentication_middleware.py
  17. 1
      server/atheneum/model/__init__.py
  18. 42
      server/atheneum/model/user_model.py
  19. 0
      server/atheneum/service/__init__.py
  20. 74
      server/atheneum/service/authentication_service.py
  21. 48
      server/atheneum/service/user_service.py
  22. 52
      server/atheneum/service/user_token_service.py
  23. 18
      server/atheneum/utility.py
  24. 10
      server/entrypoint.sh
  25. 124
      server/manage.py
  26. 1
      server/migrations/README
  27. 45
      server/migrations/alembic.ini
  28. 89
      server/migrations/env.py
  29. 24
      server/migrations/script.py.mako
  30. 56
      server/migrations/versions/96442b147e22_.py
  31. 11
      server/mypy.ini
  32. 9
      server/run_tests.sh
  33. 12
      server/server.iml
  34. 10
      server/setup.py
  35. 3
      server/test_settings.py
  36. 22
      server/tests/api/test_authentication_api.py
  37. 33
      server/tests/api/test_decorators.py
  38. 127
      server/tests/conftest.py
  39. 132
      server/tests/middleware/test_authentication_middleware.py

4
.dockerignore

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

7
.gitignore

@ -0,0 +1,7 @@
instance/
.idea
.admin_credentials
*__pycache__/
.pytest_cache/
.coverage
.mypy_cache/

19
.gitlab-ci.yml

@ -0,0 +1,19 @@
stages:
- test
Atheneum:Tests:
image: python:3.6-slim-stretch
stage: test
script:
- python3 --version
- python3 -m pip --version
- python3 -m pip install pipenv
- python3 -m pipenv --version
- cd server
- pipenv install --dev --system
- pycodestyle atheneum tests
- mypy atheneum tests
- PYTHONPATH=$(pwd) coverage run --source atheneum -m pytest
- coverage report --fail-under=85 -m --skip-covered
tags:
- docker

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

201
LICENSE

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2018 Drew Short
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

1
server/.env

@ -0,0 +1 @@
FLASK_APP=atheneum

23
server/Pipfile

@ -0,0 +1,23 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
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 = "*"
coverage = "*"
pycodestyle = "*"
mypy = "*"
mock = "*"
[requires]
python_version = "3.6"

368
server/Pipfile.lock

@ -0,0 +1,368 @@
{
"_meta": {
"hash": {
"sha256": "a8ad1b3822122643e380c48cefa0ab6356d268b7b9756f55d8040466825fea25"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.6"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"alembic": {
"hashes": [
"sha256:1cd32df9a3b8c1749082ef60ffbe05ff16617b6afadfdabc680dcb9344af33d7"
],
"version": "==0.9.10"
},
"cffi": {
"hashes": [
"sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743",
"sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef",
"sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50",
"sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f",
"sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30",
"sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93",
"sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257",
"sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b",
"sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3",
"sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e",
"sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc",
"sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04",
"sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6",
"sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359",
"sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596",
"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",
"sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184",
"sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917",
"sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f",
"sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb"
],
"version": "==1.11.5"
},
"click": {
"hashes": [
"sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d",
"sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b"
],
"index": "pypi",
"version": "==6.7"
},
"flask": {
"hashes": [
"sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48",
"sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05"
],
"index": "pypi",
"version": "==1.0.2"
},
"flask-migrate": {
"hashes": [
"sha256:493f9b3795985b9b4915bf3b7d16946697f027b73545384e7d9e3a79f989d2fe",
"sha256:b709ca8642559c3c5a81a33ab10839fa052177accd5ba821047a99db635255ed"
],
"index": "pypi",
"version": "==2.1.1"
},
"flask-sqlalchemy": {
"hashes": [
"sha256:3bc0fac969dd8c0ace01b32060f0c729565293302f0c4269beed154b46bec50b",
"sha256:5971b9852b5888655f11db634e87725a9031e170f37c0ce7851cf83497f56e53"
],
"index": "pypi",
"version": "==2.3.2"
},
"itsdangerous": {
"hashes": [
"sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519"
],
"version": "==0.24"
},
"jinja2": {
"hashes": [
"sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd",
"sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4"
],
"version": "==2.10"
},
"mako": {
"hashes": [
"sha256:4e02fde57bd4abb5ec400181e4c314f56ac3e49ba4fb8b0d50bba18cb27d25ae"
],
"version": "==1.0.7"
},
"markupsafe": {
"hashes": [
"sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"
],
"version": "==1.0"
},
"pycparser": {
"hashes": [
"sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226"
],
"version": "==2.18"
},
"pynacl": {
"hashes": [
"sha256:04e30e5bdeeb2d5b34107f28cd2f5bbfdc6c616f3be88fc6f53582ff1669eeca",
"sha256:0bfa0d94d2be6874e40f896e0a67e290749151e7de767c5aefbad1121cad7512",
"sha256:11aa4e141b2456ce5cecc19c130e970793fa3a2c2e6fbb8ad65b28f35aa9e6b6",
"sha256:13bdc1fe084ff9ac7653ae5a924cae03bf4bb07c6667c9eb5b6eb3c570220776",
"sha256:14339dc233e7a9dda80a3800e64e7ff89d0878ba23360eea24f1af1b13772cac",
"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"
],
"index": "pypi",
"version": "==1.2.1"
},
"python-dateutil": {
"hashes": [
"sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0",
"sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8"
],
"version": "==2.7.3"
},
"python-editor": {
"hashes": [
"sha256:a3c066acee22a1c94f63938341d4fb374e3fdd69366ed6603d7b24bed1efc565"
],
"version": "==1.0.3"
},
"rfc3339": {
"hashes": [
"sha256:589c8b8cab8a35f85313cb80f1b0b0b3ca16a527f354beadb59882fd4473f187",
"sha256:a8167214d37449a6af9b463285baffc87a6d65e013507fd1ba63a48a60b62043"
],
"index": "pypi",
"version": "==6.0"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"sqlalchemy": {
"hashes": [
"sha256:e21e5561a85dcdf16b8520ae4daec7401c5c24558e0ce004f9b60be75c4b6957"
],
"version": "==1.2.9"
},
"werkzeug": {
"hashes": [
"sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c",
"sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b"
],
"version": "==0.14.1"
}
},
"develop": {
"atomicwrites": {
"hashes": [
"sha256:240831ea22da9ab882b551b31d4225591e5e447a68c5e188db5b89ca1d487585",
"sha256:a24da68318b08ac9c9c45029f4a10371ab5b20e4226738e150e6e7c571630ae6"
],
"version": "==1.1.5"
},
"attrs": {
"hashes": [
"sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265",
"sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b"
],
"version": "==18.1.0"
},
"coverage": {
"hashes": [
"sha256:03481e81d558d30d230bc12999e3edffe392d244349a90f4ef9b88425fac74ba",
"sha256:0b136648de27201056c1869a6c0d4e23f464750fd9a9ba9750b8336a244429ed",
"sha256:104ab3934abaf5be871a583541e8829d6c19ce7bde2923b2751e0d3ca44db60a",
"sha256:15b111b6a0f46ee1a485414a52a7ad1d703bdf984e9ed3c288a4414d3871dcbd",
"sha256:198626739a79b09fa0a2f06e083ffd12eb55449b5f8bfdbeed1df4910b2ca640",
"sha256:1c383d2ef13ade2acc636556fd544dba6e14fa30755f26812f54300e401f98f2",
"sha256:28b2191e7283f4f3568962e373b47ef7f0392993bb6660d079c62bd50fe9d162",
"sha256:2eb564bbf7816a9d68dd3369a510be3327f1c618d2357fa6b1216994c2e3d508",
"sha256:337ded681dd2ef9ca04ef5d93cfc87e52e09db2594c296b4a0a3662cb1b41249",
"sha256:3a2184c6d797a125dca8367878d3b9a178b6fdd05fdc2d35d758c3006a1cd694",
"sha256:3c79a6f7b95751cdebcd9037e4d06f8d5a9b60e4ed0cd231342aa8ad7124882a",
"sha256:3d72c20bd105022d29b14a7d628462ebdc61de2f303322c0212a054352f3b287",
"sha256:3eb42bf89a6be7deb64116dd1cc4b08171734d721e7a7e57ad64cc4ef29ed2f1",
"sha256:4635a184d0bbe537aa185a34193898eee409332a8ccb27eea36f262566585000",
"sha256:56e448f051a201c5ebbaa86a5efd0ca90d327204d8b059ab25ad0f35fbfd79f1",
"sha256:5a13ea7911ff5e1796b6d5e4fbbf6952381a611209b736d48e675c2756f3f74e",
"sha256:69bf008a06b76619d3c3f3b1983f5145c75a305a0fea513aca094cae5c40a8f5",
"sha256:6bc583dc18d5979dc0f6cec26a8603129de0304d5ae1f17e57a12834e7235062",
"sha256:701cd6093d63e6b8ad7009d8a92425428bc4d6e7ab8d75efbb665c806c1d79ba",
"sha256:7608a3dd5d73cb06c531b8925e0ef8d3de31fed2544a7de6c63960a1e73ea4bc",
"sha256:76ecd006d1d8f739430ec50cc872889af1f9c1b6b8f48e29941814b09b0fd3cc",
"sha256:7aa36d2b844a3e4a4b356708d79fd2c260281a7390d678a10b91ca595ddc9e99",
"sha256:7d3f553904b0c5c016d1dad058a7554c7ac4c91a789fca496e7d8347ad040653",
"sha256:7e1fe19bd6dce69d9fd159d8e4a80a8f52101380d5d3a4d374b6d3eae0e5de9c",
"sha256:8c3cb8c35ec4d9506979b4cf90ee9918bc2e49f84189d9bf5c36c0c1119c6558",
"sha256:9d6dd10d49e01571bf6e147d3b505141ffc093a06756c60b053a859cb2128b1f",
"sha256:9e112fcbe0148a6fa4f0a02e8d58e94470fc6cb82a5481618fea901699bf34c4",
"sha256:ac4fef68da01116a5c117eba4dd46f2e06847a497de5ed1d64bb99a5fda1ef91",
"sha256:b8815995e050764c8610dbc82641807d196927c3dbed207f0a079833ffcf588d",
"sha256:be6cfcd8053d13f5f5eeb284aa8a814220c3da1b0078fa859011c7fffd86dab9",
"sha256:c1bb572fab8208c400adaf06a8133ac0712179a334c09224fb11393e920abcdd",
"sha256:de4418dadaa1c01d497e539210cb6baa015965526ff5afc078c57ca69160108d",
"sha256:e05cb4d9aad6233d67e0541caa7e511fa4047ed7750ec2510d466e806e0255d6",
"sha256:e4d96c07229f58cb686120f168276e434660e4358cc9cf3b0464210b04913e77",
"sha256:f3f501f345f24383c0000395b26b726e46758b71393267aeae0bd36f8b3ade80",
"sha256:f8a923a85cb099422ad5a2e345fe877bbc89a8a8b23235824a93488150e45f6e"
],
"index": "pypi",
"version": "==4.5.1"
},
"mock": {
"hashes": [
"sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1",
"sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba"
],
"index": "pypi",
"version": "==2.0.0"
},
"more-itertools": {
"hashes": [
"sha256:2b6b9893337bfd9166bee6a62c2b0c9fe7735dcf85948b387ec8cba30e85d8e8",
"sha256:6703844a52d3588f951883005efcf555e49566a48afd4db4e965d69b883980d3",
"sha256:a18d870ef2ffca2b8463c0070ad17b5978056f403fb64e3f15fe62a52db21cc0"
],
"version": "==4.2.0"
},
"mypy": {
"hashes": [
"sha256:1b899802a89b67bb68f30d788bba49b61b1f28779436f06b75c03495f9d6ea5c",
"sha256:f472645347430282d62d1f97d12ccb8741f19f1572b7cf30b58280e4e0818739"
],
"index": "pypi",
"version": "==0.610"
},
"pbr": {
"hashes": [
"sha256:3747c6f017f2dc099986c325239661948f9f5176f6880d9fdef164cb664cd665",
"sha256:a9c27eb8f0e24e786e544b2dbaedb729c9d8546342b5a6818d8eda098ad4340d"
],
"version": "==4.0.4"
},
"pluggy": {
"hashes": [
"sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff",
"sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c",
"sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5"
],
"version": "==0.6.0"
},
"py": {
"hashes": [
"sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7",
"sha256:e31fb2767eb657cbde86c454f02e99cb846d3cd9d61b318525140214fdc0e98e"
],
"version": "==1.5.4"
},
"pycodestyle": {
"hashes": [
"sha256:74abc4e221d393ea5ce1f129ea6903209940c1ecd29e002e8c6933c2b21026e0",
"sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83",
"sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a"
],
"index": "pypi",
"version": "==2.4.0"
},
"pytest": {
"hashes": [
"sha256:0453c8676c2bee6feb0434748b068d5510273a916295fd61d306c4f22fbfd752",
"sha256:4b208614ae6d98195430ad6bde03641c78553acee7c83cec2e85d613c0cd383d"
],
"index": "pypi",
"version": "==3.6.3"
},
"python-dotenv": {
"hashes": [
"sha256:4965ed170bf51c347a89820e8050655e9c25db3837db6602e906b6d850fad85c",
"sha256:509736185257111613009974e666568a1b031b028b61b500ef1ab4ee780089d5"
],
"index": "pypi",
"version": "==0.8.2"
},
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"typed-ast": {
"hashes": [
"sha256:0948004fa228ae071054f5208840a1e88747a357ec1101c17217bfe99b299d58",
"sha256:10703d3cec8dcd9eef5a630a04056bbc898abc19bac5691612acba7d1325b66d",
"sha256:1f6c4bd0bdc0f14246fd41262df7dfc018d65bb05f6e16390b7ea26ca454a291",
"sha256:25d8feefe27eb0303b73545416b13d108c6067b846b543738a25ff304824ed9a",
"sha256:29464a177d56e4e055b5f7b629935af7f49c196be47528cc94e0a7bf83fbc2b9",
"sha256:2e214b72168ea0275efd6c884b114ab42e316de3ffa125b267e732ed2abda892",
"sha256:3e0d5e48e3a23e9a4d1a9f698e32a542a4a288c871d33ed8df1b092a40f3a0f9",
"sha256:519425deca5c2b2bdac49f77b2c5625781abbaf9a809d727d3a5596b30bb4ded",
"sha256:57fe287f0cdd9ceaf69e7b71a2e94a24b5d268b35df251a88fef5cc241bf73aa",
"sha256:668d0cec391d9aed1c6a388b0d5b97cd22e6073eaa5fbaa6d2946603b4871efe",
"sha256:68ba70684990f59497680ff90d18e756a47bf4863c604098f10de9716b2c0bdd",
"sha256:6de012d2b166fe7a4cdf505eee3aaa12192f7ba365beeefaca4ec10e31241a85",
"sha256:79b91ebe5a28d349b6d0d323023350133e927b4de5b651a8aa2db69c761420c6",
"sha256:8550177fa5d4c1f09b5e5f524411c44633c80ec69b24e0e98906dd761941ca46",
"sha256:898f818399cafcdb93cbbe15fc83a33d05f18e29fb498ddc09b0214cdfc7cd51",
"sha256:94b091dc0f19291adcb279a108f5d38de2430411068b219f41b343c03b28fb1f",
"sha256:a26863198902cda15ab4503991e8cf1ca874219e0118cbf07c126bce7c4db129",
"sha256:a8034021801bc0440f2e027c354b4eafd95891b573e12ff0418dec385c76785c",
"sha256:bc978ac17468fe868ee589c795d06777f75496b1ed576d308002c8a5756fb9ea",
"sha256:c05b41bc1deade9f90ddc5d988fe506208019ebba9f2578c622516fd201f5863",
"sha256:c9b060bd1e5a26ab6e8267fd46fc9e02b54eb15fffb16d112d4c7b1c12987559",
"sha256:edb04bdd45bfd76c8292c4d9654568efaedf76fe78eb246dde69bdb13b2dad87",
"sha256:f19f2a4f547505fe9072e15f6f4ae714af51b5a681a97f187971f50c283193b6"
],
"version": "==1.1.0"
}
}
}

83
server/atheneum/__init__.py

@ -0,0 +1,83 @@
import os
from logging.config import dictConfig
from flask import Flask
from flask_migrate import Migrate, upgrade
from flask_sqlalchemy import SQLAlchemy
from atheneum import utility
db: SQLAlchemy = SQLAlchemy()
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: dict = None) -> Flask:
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)
default_database_uri = 'sqlite:///{}/atheneum.db'.format(data_directory)
app.config.from_mapping(
SECRET_KEY='dev',
SQLALCHEMY_DATABASE_URI=default_database_uri,
SQLALCHEMY_TRACK_MODIFICATIONS=False
)
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_SETTINGS', None):
app.config.from_envvar('ATHENEUM_SETTINGS')
else:
app.logger.debug('Loading test configuration')
app.config.from_object(test_config)
try:
os.makedirs(app.instance_path)
except OSError:
pass
app.json_encoder = utility.CustomJSONEncoder
app.logger.debug('Initializing Application')
db.init_app(app)
app.logger.debug('Registering Database Models')
Migrate(app, db)
return app
def register_blueprints(app: Flask) -> None:
from atheneum.api import auth_blueprint
app.register_blueprint(auth_blueprint)
app = create_app()
register_blueprints(app)
def init_db() -> None:
"""Clear existing data and create new tables."""
upgrade('migrations')
if __name__ == "__main__":
app.run()

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({'last_login_time': 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, Any
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: list, **kwargs: dict) -> Any:
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

4
server/atheneum/default_settings.py

@ -0,0 +1,4 @@
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

0
server/atheneum/middleware/__init__.py

87
server/atheneum/middleware/authentication_middleware.py

@ -0,0 +1,87 @@
import base64
from functools import wraps
from typing import Optional, Callable, Any
from flask import request, Response, g
from werkzeug.datastructures import Authorization
from werkzeug.http import bytes_to_wsgi, wsgi_to_bytes
from atheneum.service import (
authentication_service,
user_service,
user_token_service
)
def authenticate_with_password(name: str, password: str) -> bool:
user = user_service.find_by_name(name)
if user is not None \
and authentication_service.is_valid_password(user, password):
g.user = user
return True
return False
def authenticate_with_token(name: str, token: str) -> bool:
user = user_service.find_by_name(name)
if user is not None:
user_token = user_token_service.find_by_user_and_token(user, token)
if user is not None \
and authentication_service.is_valid_token(user_token):
g.user = user
g.user_token = user_token
return True
return False
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: str) -> Optional[Authorization]:
if not header_value:
return None
value = wsgi_to_bytes(header_value)
try:
auth_type, auth_info = value.split(None, 1)
auth_type = auth_type.lower()
except ValueError:
return None
if auth_type == b'token':
try:
username, token = base64.b64decode(auth_info).split(b':', 1)
except Exception:
return None
return Authorization('token', {'username': bytes_to_wsgi(username),
'password': bytes_to_wsgi(token)})
return None
def require_basic_auth(func: Callable) -> Callable:
@wraps(func)
def decorate(*args: list, **kwargs: dict) -> Any:
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: list, **kwargs: dict) -> Any:
token = parse_token_authorization_header(
request.headers.get('Authorization', None))
if token and authenticate_with_token(token.username, token.password):
return func(*args, **kwargs)
else:
return authentication_failed('Bearer')
return decorate

1
server/atheneum/model/__init__.py

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

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]) -> None:
"""
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) -> 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)

48
server/atheneum/service/user_service.py

@ -0,0 +1,48 @@
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:
pw_hash, pw_revision = authentication_service.get_password_hash(password)
new_user = User(
name=name,
role=role,
password_hash=pw_hash,
password_revision=pw_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) -> None:
user.last_login_time = datetime.now()
db.session.commit()
def update_password(user: User, password: str) -> None:
pw_hash, pw_revision = authentication_service.get_password_hash(
password)
user.password_hash = pw_hash
user.password_revision = pw_revision
db.session.commit()
def find_by_name(name: str) -> Optional[User]:
return User.query.filter_by(name=name).first()

52
server/atheneum/service/user_token_service.py

@ -0,0 +1,52 @@
from datetime import datetime
from typing import Optional
from atheneum import db
from atheneum.model import User, UserToken
from atheneum.service import authentication_service
def create(
user: User,
note: Optional[str] = None,
enabled: bool = True,
expiration_time: Optional[datetime] = None) -> UserToken:
"""
Create and save a UserToken
:param user: The User object to bind the token to
:param note: An optional field to store additional information about a
token
:param enabled: A boolean to indicate whether a token can be considered
eligible for authentication
:param expiration_time: An optional argument to determine when the token
becomes invalid as a means of authentication. Defaults to None, which means
no expiration
:return:
"""
token = authentication_service.generate_token()
user_token = UserToken(
user_id=user.id,
token=token.__str__(),
note=note,
enabled=enabled,
creation_time=datetime.now(),
expiration_time=expiration_time,
version=0)
db.session.add(user_token)
db.session.commit()
return user_token
def delete(user_token: UserToken) -> bool:
existing_user_token = db.session.delete(user_token)
if existing_user_token is None:
db.session.commit()
return True
return False
def find_by_user_and_token(user: User, token: str) -> Optional[UserToken]:
return UserToken.query.filter_by(user_id=user.id, token=token).first()

18
server/atheneum/utility.py

@ -0,0 +1,18 @@
from datetime import date
from typing import Any
import rfc3339
from flask.json import JSONEncoder
class CustomJSONEncoder(JSONEncoder):
def default(self, obj: Any) -> Any:
try:
if isinstance(obj, date):
return rfc3339.format(obj)
iterable = iter(obj)
except TypeError:
pass
else:
return list(iterable)
return JSONEncoder.default(self, obj)

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()

1
server/migrations/README

@ -0,0 +1 @@
Generic single-database configuration.

45
server/migrations/alembic.ini

@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

89
server/migrations/env.py

@ -0,0 +1,89 @@
from __future__ import with_statement
import logging
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option('sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
engine = engine_from_config(config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
connection = engine.connect()
context.configure(connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args)
try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
server/migrations/script.py.mako

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

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')

11
server/mypy.ini

@ -0,0 +1,11 @@
[mypy]
ignore_missing_imports = True
follow_imports = skip
disallow_untyped_calls = False
disallow_untyped_defs = True
check_untyped_defs = True
disallow_subclassing_any = False
warn_redundant_casts = True
warn_unused_ignores = True
strict_optional = True
strict_boolean = False

9
server/run_tests.sh

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -e
set -x
pycodestyle atheneum tests
mypy atheneum tests
PYTHONPATH=$(pwd) coverage run --source atheneum -m pytest
coverage report --fail-under=85 -m --skip-covered

12
server/server.iml

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/atheneum" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

10
server/setup.py

@ -0,0 +1,10 @@
from setuptools import setup
setup(
name='atheneum',
packages=['atheneum'],
include_package_data=True,
install_requires=[
'flask',
],
)

3
server/test_settings.py

@ -0,0 +1,3 @@
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

22
server/tests/api/test_authentication_api.py

@ -0,0 +1,22 @@
from tests.conftest import AuthActions
def test_login_happy_path(auth: AuthActions):
result = auth.login()
assert result.status_code == 200
assert result.json['token'] is not None and len(result.json['token']) > 0
def test_bump_happy_path(auth: AuthActions):
auth.login()
result = auth.bump()
assert result.status_code == 200
assert (result.json['last_login_time'] is not None
and len(result.json['last_login_time']) > 0)
def test_logout_happy_path(auth: AuthActions):
auth.login()
result = auth.logout()
assert result.status_code == 200
assert result.json is None

33
server/tests/api/test_decorators.py

@ -0,0 +1,33 @@
from typing import Any
from flask import Response, Flask
from atheneum.api.decorators import return_json
from atheneum.api.model import APIResponse
@return_json
def return_jsonified_result(obj: Any) -> Any:
return obj
def test_return_json_response():
result = return_jsonified_result(Response(status=200))
assert isinstance(result, Response)
assert result.status_code == 200
def test_return_json_apiresponse(app: Flask):
with app.app_context():
result = return_jsonified_result(APIResponse(payload={}, status=200))
assert len(result) == 2
assert isinstance(result[0], Response)
assert isinstance(result[1], int)
assert result[0].status_code == 200
def test_return_json_dict(app: Flask):
with app.app_context():
result = return_jsonified_result({'status': 200})
assert isinstance(result, Response)
assert result.status_code == 200

127
server/tests/conftest.py

@ -0,0 +1,127 @@
import base64
import os
import random
import string
import tempfile
from typing import Tuple, Any
import pytest
from flask import Flask
from flask.testing import FlaskClient, FlaskCliRunner
from werkzeug.test import Client
from atheneum import create_app, init_db, register_blueprints
from atheneum.model import User
from atheneum.service import user_service
def add_test_user() -> Tuple[str, str]:
test_username = 'test_' + ''.join(
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)
return test_username, test_password
@pytest.fixture
def app() -> Flask:
"""Create and configure a new app instance for each test."""
# create a temporary file to isolate the database for each test
db_fd, db_path = tempfile.mkstemp()
# create the app with common test config
app = create_app({
'TESTING': True,
'DATABASE': db_path,
})
register_blueprints(app)
# create the database and load test data
with app.app_context():
init_db()
test_username, test_password = add_test_user()
app.config['test_username'] = test_username
app.config['test_password'] = test_password
# get_db().executescript(_data_sql)
yield app
# close and remove the temporary database
os.close(db_fd)
os.unlink(db_path)
@pytest.fixture
def client(app: Flask) -> FlaskClient:
"""A test client for the app."""
return app.test_client()
@pytest.fixture
def runner(app: Flask) -> FlaskCliRunner:
"""A test runner for the app's Click commands."""
return app.test_cli_runner()
class AuthActions(object):
def __init__(self,
client: Client,
username: str = "",
password: str = "") -> None:
self._client = client
self.username: str = username
self.password: str = password
self.token: str = ""
def configure(self, username: str, password: str) -> Any:
self.username = username
self.password = password
return self
def login(self) -> Any:
auth_header = self.get_authorization_header_basic()
result = self._client.post(
'/auth/login',
headers={
auth_header[0]: auth_header[1]
}
)
self.token = result.json['token']
return result
def bump(self) -> Any:
auth_header = self.get_authorization_header_token()
return self._client.post(
'/auth/bump',
headers={
auth_header[0]: auth_header[1]
}
)
def logout(self) -> Any:
auth_header = self.get_authorization_header_token()
return self._client.post(
'/auth/logout',
headers={
auth_header[0]: auth_header[1]
}
)
def get_authorization_header_basic(self) -> Tuple[str, str]:
credentials = base64.b64encode(
'{}:{}'.format(self.username, self.password).encode('utf8')) \
.decode('utf8').strip()
return 'Authorization', 'Basic {}'.format(credentials)
def get_authorization_header_token(self) -> Tuple[str, str]:
credentials = base64.b64encode(
'{}:{}'.format(self.username, self.token).encode('utf8')) \
.decode('utf8').strip()
return 'Authorization', 'Token {}'.format(credentials)
@pytest.fixture
def auth(client: Client) -> AuthActions:
return AuthActions(client,
client.application.config.get('test_username'),
client.application.config.get('test_password'))

132
server/tests/middleware/test_authentication_middleware.py

@ -0,0 +1,132 @@
from mock import patch, MagicMock, Mock
from atheneum.middleware.authentication_middleware import \
authenticate_with_password, authenticate_with_token
middleware_module = 'atheneum.middleware.authentication_middleware'
@patch(middleware_module + '.g')
@patch(middleware_module + '.authentication_service.is_valid_password')
@patch(middleware_module + '.user_service.find_by_name')
def test_authenticate_with_password_happy_path(
mock_user_service: MagicMock,
mock_authentication_service: MagicMock,
mock_g: MagicMock):
mock_g.user = Mock()
mock_user_service.return_value = Mock()
mock_authentication_service.return_value = True
assert authenticate_with_password('test', 'test')
mock_user_service.assert_called_once()
mock_authentication_service.assert_called_once()
mock_g.user.assert_not_called()
@patch(middleware_module + '.g')
@patch(middleware_module + '.authentication_service.is_valid_password')
@patch(middleware_module + '.user_service.find_by_name')
def test_authenticate_with_password_no_user(
mock_user_service: MagicMock,
mock_authentication_service: MagicMock,
mock_g: MagicMock):
mock_g.user = Mock()
mock_user_service.return_value = None
mock_authentication_service.return_value = True
assert not authenticate_with_password('test', 'test')
mock_user_service.assert_called_once()
mock_authentication_service.assert_not_called()
mock_g.user.assert_not_called()
@patch(middleware_module + '.g')
@patch(middleware_module + '.authentication_service.is_valid_password')
@patch(middleware_module + '.user_service.find_by_name')
def test_authenticate_with_password_invalid_password(
mock_user_service: MagicMock,
mock_authentication_service: MagicMock,
mock_g: MagicMock):
mock_g.user = Mock()
mock_user_service.return_value = Mock()
mock_authentication_service.return_value = False
assert not authenticate_with_password('test', 'test')
mock_user_service.assert_called_once()
mock_authentication_service.assert_called_once()
mock_g.user.assert_not_called()
@patch(middleware_module + '.g')
@patch(middleware_module + '.authentication_service.is_valid_token')
@patch(middleware_module + '.user_token_service.find_by_user_and_token')
@patch(middleware_module + '.user_service.find_by_name')
def test_authenticate_with_token_happy_path(
mock_user_service: MagicMock,
mock_user_token_service: MagicMock,
mock_authentication_service: MagicMock,
mock_g: MagicMock):
mock_g.user = Mock()
mock_user_service.return_value = Mock()
mock_user_token_service.return_value = Mock()
mock_authentication_service.return_value = True
assert authenticate_with_token('test', 'test')
mock_user_service.assert_called_once()
mock_user_token_service.assert_called_once()
mock_authentication_service.assert_called_once()
mock_g.user.assert_not_called()
@patch(middleware_module + '.g')
@patch(middleware_module + '.authentication_service.is_valid_token')
@patch(middleware_module + '.user_token_service.find_by_user_and_token')
@patch(middleware_module + '.user_service.find_by_name')
def test_authenticate_with_token_no_user(
mock_user_service: MagicMock,
mock_user_token_service: MagicMock,
mock_authentication_service: MagicMock,
mock_g: MagicMock):
mock_g.user = Mock()
mock_user_service.return_value = None
assert not authenticate_with_token('test', 'test')
mock_user_service.assert_called_once()
mock_user_token_service.assert_not_called()
mock_authentication_service.assert_not_called()
mock_g.user.assert_not_called()
@patch(middleware_module + '.g')
@patch(middleware_module + '.authentication_service.is_valid_token')
@patch(middleware_module + '.user_token_service.find_by_user_and_token')
@patch(middleware_module + '.user_service.find_by_name')
def test_authenticate_with_token_no_user_token(
mock_user_service: MagicMock,
mock_user_token_service: MagicMock,
mock_authentication_service: MagicMock,
mock_g: MagicMock):
mock_g.user = Mock()
mock_user_service.return_value = Mock()
mock_user_token_service.return_value = None
mock_authentication_service.return_value = False
assert not authenticate_with_token('test', 'test')
mock_user_service.assert_called_once()
mock_user_token_service.assert_called_once()
mock_authentication_service.assert_called_once()
mock_g.user.assert_not_called()
@patch(middleware_module + '.g')
@patch(middleware_module + '.authentication_service.is_valid_token')
@patch(middleware_module + '.user_token_service.find_by_user_and_token')
@patch(middleware_module + '.user_service.find_by_name')
def test_authenticate_with_token_invalid_token(
mock_user_service: MagicMock,
mock_user_token_service: MagicMock,
mock_authentication_service: MagicMock,
mock_g: MagicMock):
mock_g.user = Mock()
mock_user_service.return_value = Mock()
mock_user_token_service.return_value = Mock()
mock_authentication_service.return_value = False
assert not authenticate_with_token('test', 'test')
mock_user_service.assert_called_once()
mock_user_token_service.assert_called_once()
mock_authentication_service.assert_called_once()
mock_g.user.assert_not_called()
Loading…
Cancel
Save