commit bcbb1750b8a77a6dc813a8b1ee242b68ebc941a7 Author: Drew Short Date: Wed Jul 4 22:51:05 2018 -0500 Initial copy from Atheneum project diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6644bf7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +server/instance/ +server/setup.py +server/test/ +.admin_credentials \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65752d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +instance/ +.idea +.admin_credentials +*__pycache__/ +.pytest_cache/ +.coverage +.mypy_cache/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..3f6cb05 --- /dev/null +++ b/.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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..582ded4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.6-slim-stretch +MAINTAINER Drew Short + +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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4eb2652 --- /dev/null +++ b/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. diff --git a/server/.env b/server/.env new file mode 100644 index 0000000..62fbcfb --- /dev/null +++ b/server/.env @@ -0,0 +1 @@ +FLASK_APP=atheneum \ No newline at end of file diff --git a/server/Pipfile b/server/Pipfile new file mode 100644 index 0000000..dbb7808 --- /dev/null +++ b/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" diff --git a/server/Pipfile.lock b/server/Pipfile.lock new file mode 100644 index 0000000..e79655f --- /dev/null +++ b/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" + } + } +} diff --git a/server/atheneum/__init__.py b/server/atheneum/__init__.py new file mode 100644 index 0000000..a9f68a6 --- /dev/null +++ b/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() diff --git a/server/atheneum/api/__init__.py b/server/atheneum/api/__init__.py new file mode 100644 index 0000000..7802de1 --- /dev/null +++ b/server/atheneum/api/__init__.py @@ -0,0 +1 @@ +from atheneum.api.authentication_api import auth_blueprint diff --git a/server/atheneum/api/authentication_api.py b/server/atheneum/api/authentication_api.py new file mode 100644 index 0000000..e2143c6 --- /dev/null +++ b/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) diff --git a/server/atheneum/api/decorators.py b/server/atheneum/api/decorators.py new file mode 100644 index 0000000..8d3e9b2 --- /dev/null +++ b/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 diff --git a/server/atheneum/api/model.py b/server/atheneum/api/model.py new file mode 100644 index 0000000..9162fd4 --- /dev/null +++ b/server/atheneum/api/model.py @@ -0,0 +1,6 @@ +from typing import Any, NamedTuple + + +class APIResponse(NamedTuple): + payload: Any + status: int diff --git a/server/atheneum/default_settings.py b/server/atheneum/default_settings.py new file mode 100644 index 0000000..15a1584 --- /dev/null +++ b/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 diff --git a/server/atheneum/middleware/__init__.py b/server/atheneum/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/atheneum/middleware/authentication_middleware.py b/server/atheneum/middleware/authentication_middleware.py new file mode 100644 index 0000000..3113a13 --- /dev/null +++ b/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 diff --git a/server/atheneum/model/__init__.py b/server/atheneum/model/__init__.py new file mode 100644 index 0000000..7949bd4 --- /dev/null +++ b/server/atheneum/model/__init__.py @@ -0,0 +1 @@ +from atheneum.model.user_model import User, UserToken diff --git a/server/atheneum/model/user_model.py b/server/atheneum/model/user_model.py new file mode 100644 index 0000000..ce839e0 --- /dev/null +++ b/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) diff --git a/server/atheneum/service/__init__.py b/server/atheneum/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/atheneum/service/authentication_service.py b/server/atheneum/service/authentication_service.py new file mode 100644 index 0000000..d859be1 --- /dev/null +++ b/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) diff --git a/server/atheneum/service/user_service.py b/server/atheneum/service/user_service.py new file mode 100644 index 0000000..dd96b81 --- /dev/null +++ b/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() diff --git a/server/atheneum/service/user_token_service.py b/server/atheneum/service/user_token_service.py new file mode 100644 index 0000000..65b4dad --- /dev/null +++ b/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() diff --git a/server/atheneum/utility.py b/server/atheneum/utility.py new file mode 100644 index 0000000..2277381 --- /dev/null +++ b/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) diff --git a/server/entrypoint.sh b/server/entrypoint.sh new file mode 100755 index 0000000..033507e --- /dev/null +++ b/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 \ No newline at end of file diff --git a/server/manage.py b/server/manage.py new file mode 100644 index 0000000..cb8d238 --- /dev/null +++ b/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() diff --git a/server/migrations/README b/server/migrations/README new file mode 100755 index 0000000..98e4f9c --- /dev/null +++ b/server/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/server/migrations/alembic.ini b/server/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/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 diff --git a/server/migrations/env.py b/server/migrations/env.py new file mode 100755 index 0000000..9be4103 --- /dev/null +++ b/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() diff --git a/server/migrations/script.py.mako b/server/migrations/script.py.mako new file mode 100755 index 0000000..2c01563 --- /dev/null +++ b/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"} diff --git a/server/migrations/versions/96442b147e22_.py b/server/migrations/versions/96442b147e22_.py new file mode 100644 index 0000000..f8916ba --- /dev/null +++ b/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') diff --git a/server/mypy.ini b/server/mypy.ini new file mode 100644 index 0000000..c440ed6 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/server/run_tests.sh b/server/run_tests.sh new file mode 100755 index 0000000..e770818 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/server/server.iml b/server/server.iml new file mode 100644 index 0000000..8fc9fb7 --- /dev/null +++ b/server/server.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/server/setup.py b/server/setup.py new file mode 100644 index 0000000..8abfb43 --- /dev/null +++ b/server/setup.py @@ -0,0 +1,10 @@ +from setuptools import setup + +setup( + name='atheneum', + packages=['atheneum'], + include_package_data=True, + install_requires=[ + 'flask', + ], +) \ No newline at end of file diff --git a/server/test_settings.py b/server/test_settings.py new file mode 100644 index 0000000..3ca8736 --- /dev/null +++ b/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 diff --git a/server/tests/api/test_authentication_api.py b/server/tests/api/test_authentication_api.py new file mode 100644 index 0000000..d95f7e2 --- /dev/null +++ b/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 diff --git a/server/tests/api/test_decorators.py b/server/tests/api/test_decorators.py new file mode 100644 index 0000000..cf1ed57 --- /dev/null +++ b/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 diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..c14305d --- /dev/null +++ b/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')) diff --git a/server/tests/middleware/test_authentication_middleware.py b/server/tests/middleware/test_authentication_middleware.py new file mode 100644 index 0000000..d962df0 --- /dev/null +++ b/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()