├── .gitignore ├── LICENCE ├── Pipfile ├── Pipfile.lock ├── README.md ├── app ├── __init__.py ├── auth │ ├── __init__.py │ ├── exceptions.py │ ├── helpers.py │ ├── service.py │ └── views.py ├── config.py ├── exceptions.py ├── models.py └── utils.py ├── docs └── api.yaml ├── manage.py ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── 0e24da9edb9e_.py ├── run.py └── tests ├── __init__.py ├── conftest.py ├── helpers.py ├── test_app.py └── test_auth.py /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .mypy_cache/ 3 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 Petr Stribny http://stribny.name 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | flask = "==1.0.2" 8 | pytest = "==3.8.2" 9 | sqlalchemy = "==1.3.3" 10 | flask-sqlalchemy = "==2.3.2" 11 | "psycopg2" = "==2.7.5" 12 | bcrypt = "==3.1.4" 13 | pyjwt = "==1.6.4" 14 | validate-email = "==1.3" 15 | python-usernames = "==0.2.2" 16 | flask-migrate = "==2.2.1" 17 | pytz = "*" 18 | 19 | [dev-packages] 20 | rope = "*" 21 | 22 | [requires] 23 | python_version = "3.7" 24 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "4e10110c03d5792e9713eb779db47886c1288d3c1004cde76173162eb577b32d" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "alembic": { 20 | "hashes": [ 21 | "sha256:828dcaa922155a2b7166c4f36ec45268944e4055c86499bd14319b4c8c0094b7" 22 | ], 23 | "version": "==1.0.10" 24 | }, 25 | "atomicwrites": { 26 | "hashes": [ 27 | "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", 28 | "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" 29 | ], 30 | "version": "==1.3.0" 31 | }, 32 | "attrs": { 33 | "hashes": [ 34 | "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", 35 | "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" 36 | ], 37 | "version": "==19.1.0" 38 | }, 39 | "bcrypt": { 40 | "hashes": [ 41 | "sha256:01477981abf74e306e8ee31629a940a5e9138de000c6b0898f7f850461c4a0a5", 42 | "sha256:054d6e0acaea429e6da3613fcd12d05ee29a531794d96f6ab959f29a39f33391", 43 | "sha256:0872eeecdf9a429c1420158500eedb323a132bc5bf3339475151c52414729e70", 44 | "sha256:09a3b8c258b815eadb611bad04ca15ec77d86aa9ce56070e1af0d5932f17642a", 45 | "sha256:0f317e4ffbdd15c3c0f8ab5fbd86aa9aabc7bea18b5cc5951b456fe39e9f738c", 46 | "sha256:2788c32673a2ad0062bea850ab73cffc0dba874db10d7a3682b6f2f280553f20", 47 | "sha256:321d4d48be25b8d77594d8324c0585c80ae91ac214f62db9098734e5e7fb280f", 48 | "sha256:346d6f84ff0b493dbc90c6b77136df83e81f903f0b95525ee80e5e6d5e4eef84", 49 | "sha256:34dd60b90b0f6de94a89e71fcd19913a30e83091c8468d0923a93a0cccbfbbff", 50 | "sha256:3b4c23300c4eded8895442c003ae9b14328ae69309ac5867e7530de8bdd7875d", 51 | "sha256:43d1960e7db14042319c46925892d5fa99b08ff21d57482e6f5328a1aca03588", 52 | "sha256:49e96267cd9be55a349fd74f9852eb9ae2c427cd7f6455d0f1765d7332292832", 53 | "sha256:63e06ffdaf4054a89757a3a1ab07f1b922daf911743114a54f7c561b9e1baa58", 54 | "sha256:67ed1a374c9155ec0840214ce804616de49c3df9c5bc66740687c1c9b1cd9e8d", 55 | "sha256:6b662a5669186439f4f583636c8d6ea77cf92f7cfe6aae8d22edf16c36840574", 56 | "sha256:6efd9ca20aefbaf2e7e6817a2c6ed4a50ff6900fafdea1bcb1d0e9471743b144", 57 | "sha256:8569844a5d8e1fdde4d7712a05ab2e6061343ac34af6e7e3d7935b2bd1907bfd", 58 | "sha256:8629ea6a8a59f865add1d6a87464c3c676e60101b8d16ef404d0a031424a8491", 59 | "sha256:988cac675e25133d01a78f2286189c1f01974470817a33eaf4cfee573cfb72a5", 60 | "sha256:9a6fedda73aba1568962f7543a1f586051c54febbc74e87769bad6a4b8587c39", 61 | "sha256:9eced8962ce3b7124fe20fd358cf8c7470706437fa064b9874f849ad4c5866fc", 62 | "sha256:a005ed6163490988711ff732386b08effcbf8df62ae93dd1e5bda0714fad8afb", 63 | "sha256:ae35dbcb6b011af6c840893b32399252d81ff57d52c13e12422e16b5fea1d0fb", 64 | "sha256:b1e8491c6740f21b37cca77bc64677696a3fb9f32360794d57fa8477b7329eda", 65 | "sha256:c906bdb482162e9ef48eea9f8c0d967acceb5c84f2d25574c7d2a58d04861df1", 66 | "sha256:cb18ffdc861dbb244f14be32c47ab69604d0aca415bee53485fcea4f8e93d5ef", 67 | "sha256:cc2f24dc1c6c88c56248e93f28d439ee4018338567b0bbb490ea26a381a29b1e", 68 | "sha256:d860c7fff18d49e20339fc6dffc2d485635e36d4b2cccf58f45db815b64100b4", 69 | "sha256:d86da365dda59010ba0d1ac45aa78390f56bf7f992e65f70b3b081d5e5257b09", 70 | "sha256:e22f0997622e1ceec834fd25947dc2ee2962c2133ea693d61805bc867abaf7ea", 71 | "sha256:f2fe545d27a619a552396533cddf70d83cecd880a611cdfdbb87ca6aec52f66b", 72 | "sha256:f425e925485b3be48051f913dbe17e08e8c48588fdf44a26b8b14067041c0da6", 73 | "sha256:f7fd3ed3745fe6e81e28dc3b3d76cce31525a91f32a387e1febd6b982caf8cdb", 74 | "sha256:f9210820ee4818d84658ed7df16a7f30c9fba7d8b139959950acef91745cc0f7" 75 | ], 76 | "index": "pypi", 77 | "version": "==3.1.4" 78 | }, 79 | "cffi": { 80 | "hashes": [ 81 | "sha256:041c81822e9f84b1d9c401182e174996f0bae9991f33725d059b771744290774", 82 | "sha256:046ef9a22f5d3eed06334d01b1e836977eeef500d9b78e9ef693f9380ad0b83d", 83 | "sha256:066bc4c7895c91812eff46f4b1c285220947d4aa46fa0a2651ff85f2afae9c90", 84 | "sha256:066c7ff148ae33040c01058662d6752fd73fbc8e64787229ea8498c7d7f4041b", 85 | "sha256:2444d0c61f03dcd26dbf7600cf64354376ee579acad77aef459e34efcb438c63", 86 | "sha256:300832850b8f7967e278870c5d51e3819b9aad8f0a2c8dbe39ab11f119237f45", 87 | "sha256:34c77afe85b6b9e967bd8154e3855e847b70ca42043db6ad17f26899a3df1b25", 88 | "sha256:46de5fa00f7ac09f020729148ff632819649b3e05a007d286242c4882f7b1dc3", 89 | "sha256:4aa8ee7ba27c472d429b980c51e714a24f47ca296d53f4d7868075b175866f4b", 90 | "sha256:4d0004eb4351e35ed950c14c11e734182591465a33e960a4ab5e8d4f04d72647", 91 | "sha256:4e3d3f31a1e202b0f5a35ba3bc4eb41e2fc2b11c1eff38b362de710bcffb5016", 92 | "sha256:50bec6d35e6b1aaeb17f7c4e2b9374ebf95a8975d57863546fa83e8d31bdb8c4", 93 | "sha256:55cad9a6df1e2a1d62063f79d0881a414a906a6962bc160ac968cc03ed3efcfb", 94 | "sha256:5662ad4e4e84f1eaa8efce5da695c5d2e229c563f9d5ce5b0113f71321bcf753", 95 | "sha256:59b4dc008f98fc6ee2bb4fd7fc786a8d70000d058c2bbe2698275bc53a8d3fa7", 96 | "sha256:73e1ffefe05e4ccd7bcea61af76f36077b914f92b76f95ccf00b0c1b9186f3f9", 97 | "sha256:a1f0fd46eba2d71ce1589f7e50a9e2ffaeb739fb2c11e8192aa2b45d5f6cc41f", 98 | "sha256:a2e85dc204556657661051ff4bab75a84e968669765c8a2cd425918699c3d0e8", 99 | "sha256:a5457d47dfff24882a21492e5815f891c0ca35fefae8aa742c6c263dac16ef1f", 100 | "sha256:a8dccd61d52a8dae4a825cdbb7735da530179fea472903eb871a5513b5abbfdc", 101 | "sha256:ae61af521ed676cf16ae94f30fe202781a38d7178b6b4ab622e4eec8cefaff42", 102 | "sha256:b012a5edb48288f77a63dba0840c92d0504aa215612da4541b7b42d849bc83a3", 103 | "sha256:d2c5cfa536227f57f97c92ac30c8109688ace8fa4ac086d19d0af47d134e2909", 104 | "sha256:d42b5796e20aacc9d15e66befb7a345454eef794fdb0737d1af593447c6c8f45", 105 | "sha256:dee54f5d30d775f525894d67b1495625dd9322945e7fee00731952e0368ff42d", 106 | "sha256:e070535507bd6aa07124258171be2ee8dfc19119c28ca94c9dfb7efd23564512", 107 | "sha256:e1ff2748c84d97b065cc95429814cdba39bcbd77c9c85c89344b317dc0d9cbff", 108 | "sha256:ed851c75d1e0e043cbf5ca9a8e1b13c4c90f3fbd863dacb01c0808e2b5204201" 109 | ], 110 | "version": "==1.12.3" 111 | }, 112 | "click": { 113 | "hashes": [ 114 | "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", 115 | "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" 116 | ], 117 | "version": "==7.0" 118 | }, 119 | "flask": { 120 | "hashes": [ 121 | "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", 122 | "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" 123 | ], 124 | "index": "pypi", 125 | "version": "==1.0.2" 126 | }, 127 | "flask-migrate": { 128 | "hashes": [ 129 | "sha256:83ebc105f87357ddd3968f83510d2b1092f006660b1c6ba07a4efce036ca567d", 130 | "sha256:cd1b4e6cb829eeb41c02ad9202d83bef5f4b7a036dd9fad72ce96ad1e22efb07" 131 | ], 132 | "index": "pypi", 133 | "version": "==2.2.1" 134 | }, 135 | "flask-sqlalchemy": { 136 | "hashes": [ 137 | "sha256:3bc0fac969dd8c0ace01b32060f0c729565293302f0c4269beed154b46bec50b", 138 | "sha256:5971b9852b5888655f11db634e87725a9031e170f37c0ce7851cf83497f56e53" 139 | ], 140 | "index": "pypi", 141 | "version": "==2.3.2" 142 | }, 143 | "itsdangerous": { 144 | "hashes": [ 145 | "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", 146 | "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" 147 | ], 148 | "version": "==1.1.0" 149 | }, 150 | "jinja2": { 151 | "hashes": [ 152 | "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", 153 | "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" 154 | ], 155 | "version": "==2.10.1" 156 | }, 157 | "mako": { 158 | "hashes": [ 159 | "sha256:7165919e78e1feb68b4dbe829871ea9941398178fa58e6beedb9ba14acf63965" 160 | ], 161 | "version": "==1.0.10" 162 | }, 163 | "markupsafe": { 164 | "hashes": [ 165 | "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", 166 | "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", 167 | "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", 168 | "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", 169 | "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", 170 | "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", 171 | "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", 172 | "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", 173 | "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", 174 | "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", 175 | "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", 176 | "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", 177 | "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", 178 | "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", 179 | "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", 180 | "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", 181 | "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", 182 | "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", 183 | "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", 184 | "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", 185 | "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", 186 | "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", 187 | "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", 188 | "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", 189 | "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", 190 | "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", 191 | "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", 192 | "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" 193 | ], 194 | "version": "==1.1.1" 195 | }, 196 | "more-itertools": { 197 | "hashes": [ 198 | "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", 199 | "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a" 200 | ], 201 | "version": "==7.0.0" 202 | }, 203 | "pluggy": { 204 | "hashes": [ 205 | "sha256:25a1bc1d148c9a640211872b4ff859878d422bccb59c9965e04eed468a0aa180", 206 | "sha256:964cedd2b27c492fbf0b7f58b3284a09cf7f99b0f715941fb24a439b3af1bd1a" 207 | ], 208 | "version": "==0.11.0" 209 | }, 210 | "psycopg2": { 211 | "hashes": [ 212 | "sha256:0b9e48a1c1505699a64ac58815ca99104aacace8321e455072cee4f7fe7b2698", 213 | "sha256:0f4c784e1b5a320efb434c66a50b8dd7e30a7dc047e8f45c0a8d2694bfe72781", 214 | "sha256:0fdbaa32c9eb09ef09d425dc154628fca6fa69d2f7c1a33f889abb7e0efb3909", 215 | "sha256:11fbf688d5c953c0a5ba625cc42dea9aeb2321942c7c5ed9341a68f865dc8cb1", 216 | "sha256:19eaac4eb25ab078bd0f28304a0cb08702d120caadfe76bb1e6846ed1f68635e", 217 | "sha256:3232ec1a3bf4dba97fbf9b03ce12e4b6c1d01ea3c85773903a67ced725728232", 218 | "sha256:36f8f9c216fcca048006f6dd60e4d3e6f406afde26cfb99e063f137070139eaf", 219 | "sha256:59c1a0e4f9abe970062ed35d0720935197800a7ef7a62b3a9e3a70588d9ca40b", 220 | "sha256:6506c5ff88750948c28d41852c09c5d2a49f51f28c6d90cbf1b6808e18c64e88", 221 | "sha256:6bc3e68ee16f571681b8c0b6d5c0a77bef3c589012352b3f0cf5520e674e9d01", 222 | "sha256:6dbbd7aabbc861eec6b910522534894d9dbb507d5819bc982032c3ea2e974f51", 223 | "sha256:6e737915de826650d1a5f7ff4ac6cf888a26f021a647390ca7bafdba0e85462b", 224 | "sha256:6ed9b2cfe85abc720e8943c1808eeffd41daa73e18b7c1e1a228b0b91f768ccc", 225 | "sha256:711ec617ba453fdfc66616db2520db3a6d9a891e3bf62ef9aba4c95bb4e61230", 226 | "sha256:844dacdf7530c5c612718cf12bc001f59b2d9329d35b495f1ff25045161aa6af", 227 | "sha256:86b52e146da13c896e50c5a3341a9448151f1092b1a4153e425d1e8b62fec508", 228 | "sha256:985c06c2a0f227131733ae58d6a541a5bc8b665e7305494782bebdb74202b793", 229 | "sha256:a86dfe45f4f9c55b1a2312ff20a59b30da8d39c0e8821d00018372a2a177098f", 230 | "sha256:aa3cd07f7f7e3183b63d48300666f920828a9dbd7d7ec53d450df2c4953687a9", 231 | "sha256:b1964ed645ef8317806d615d9ff006c0dadc09dfc54b99ae67f9ba7a1ec9d5d2", 232 | "sha256:b2abbff9e4141484bb89b96eb8eae186d77bc6d5ffbec6b01783ee5c3c467351", 233 | "sha256:cc33c3a90492e21713260095f02b12bee02b8d1f2c03a221d763ce04fa90e2e9", 234 | "sha256:d7de3bf0986d777807611c36e809b77a13bf1888f5c8db0ebf24b47a52d10726", 235 | "sha256:db5e3c52576cc5b93a959a03ccc3b02cb8f0af1fbbdc80645f7a215f0b864f3a", 236 | "sha256:e168aa795ffbb11379c942cf95bf813c7db9aa55538eb61de8c6815e092416f5", 237 | "sha256:e9ca911f8e2d3117e5241d5fa9aaa991cb22fb0792627eeada47425d706b5ec8", 238 | "sha256:eccf962d41ca46e6326b97c8fe0a6687b58dfc1a5f6540ed071ff1474cea749e", 239 | "sha256:efa19deae6b9e504a74347fe5e25c2cb9343766c489c2ae921b05f37338b18d1", 240 | "sha256:f4b0460a21f784abe17b496f66e74157a6c36116fa86da8bf6aa028b9e8ad5fe", 241 | "sha256:f93d508ca64d924d478fb11e272e09524698f0c581d9032e68958cfbdd41faef" 242 | ], 243 | "index": "pypi", 244 | "version": "==2.7.5" 245 | }, 246 | "py": { 247 | "hashes": [ 248 | "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", 249 | "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" 250 | ], 251 | "version": "==1.8.0" 252 | }, 253 | "pycparser": { 254 | "hashes": [ 255 | "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" 256 | ], 257 | "version": "==2.19" 258 | }, 259 | "pyjwt": { 260 | "hashes": [ 261 | "sha256:30b1380ff43b55441283cc2b2676b755cca45693ae3097325dea01f3d110628c", 262 | "sha256:4ee413b357d53fd3fb44704577afac88e72e878716116270d722723d65b42176" 263 | ], 264 | "index": "pypi", 265 | "version": "==1.6.4" 266 | }, 267 | "pytest": { 268 | "hashes": [ 269 | "sha256:7e258ee50338f4e46957f9e09a0f10fb1c2d05493fa901d113a8dafd0790de4e", 270 | "sha256:9332147e9af2dcf46cd7ceb14d5acadb6564744ddff1fe8c17f0ce60ece7d9a2" 271 | ], 272 | "index": "pypi", 273 | "version": "==3.8.2" 274 | }, 275 | "python-dateutil": { 276 | "hashes": [ 277 | "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", 278 | "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e" 279 | ], 280 | "version": "==2.8.0" 281 | }, 282 | "python-editor": { 283 | "hashes": [ 284 | "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", 285 | "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", 286 | "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", 287 | "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", 288 | "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" 289 | ], 290 | "version": "==1.0.4" 291 | }, 292 | "python-usernames": { 293 | "hashes": [ 294 | "sha256:5f97ddecd1e07549b3a221d0fdfe2930f99ad55a685a61ee9b2928e05337a231", 295 | "sha256:9ba94f3293ef6253ea1614b2f157148eada1b8e266390df0a4bbacb10b21def4" 296 | ], 297 | "index": "pypi", 298 | "version": "==0.2.2" 299 | }, 300 | "pytz": { 301 | "hashes": [ 302 | "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", 303 | "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141" 304 | ], 305 | "index": "pypi", 306 | "version": "==2019.1" 307 | }, 308 | "six": { 309 | "hashes": [ 310 | "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", 311 | "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" 312 | ], 313 | "version": "==1.12.0" 314 | }, 315 | "sqlalchemy": { 316 | "hashes": [ 317 | "sha256:91c54ca8345008fceaec987e10924bf07dcab36c442925357e5a467b36a38319" 318 | ], 319 | "index": "pypi", 320 | "version": "==1.3.3" 321 | }, 322 | "validate-email": { 323 | "hashes": [ 324 | "sha256:784719dc5f780be319cdd185dc85dd93afebdb6ebb943811bc4c7c5f9c72aeaf" 325 | ], 326 | "index": "pypi", 327 | "version": "==1.3" 328 | }, 329 | "werkzeug": { 330 | "hashes": [ 331 | "sha256:865856ebb55c4dcd0630cdd8f3331a1847a819dda7e8c750d3db6f2aa6c0209c", 332 | "sha256:a0b915f0815982fb2a09161cb8f31708052d0951c3ba433ccc5e1aa276507ca6" 333 | ], 334 | "version": "==0.15.4" 335 | } 336 | }, 337 | "develop": { 338 | "rope": { 339 | "hashes": [ 340 | "sha256:6b728fdc3e98a83446c27a91fc5d56808a004f8beab7a31ab1d7224cecc7d969", 341 | "sha256:c5c5a6a87f7b1a2095fb311135e2a3d1f194f5ecb96900fdd0a9100881f48aaf", 342 | "sha256:f0dcf719b63200d492b85535ebe5ea9b29e0d0b8aebeb87fe03fc1a65924fdaf" 343 | ], 344 | "index": "pypi", 345 | "version": "==0.14.0" 346 | } 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask API Quickstart with JSON Web Tokens, SQLAlchemy and Pytest 2 | 3 | This is a quick-start application that demonstrates how to create secured API applications using Flask and JWT. It is built with: 4 | 5 | - [Python 3](https://www.python.org/) 6 | - [Flask](http://flask.pocoo.org/) 7 | - [PyJWT](https://pyjwt.readthedocs.io/en/latest/) 8 | - [Flask-SQLAlchemy](http://flask-sqlalchemy.pocoo.org/2.1/) 9 | - [Flask-Migrate](https://flask-migrate.readthedocs.io/en/latest/) 10 | - [Pytest](https://docs.pytest.org/) 11 | 12 | I wrote an article about creating the quickstart: [Flask API Quickstart Application with JSON Web Tokens, SQLAlchemy and Pytest](https://stribny.name/blog/2018/10/flask-api-quickstart-application-with-json-web-tokens-sqlalchemy-and-pytest). 13 | 14 | ## Requirements 15 | 16 | You will need Python 3 installed, together with [Pipenv](https://pipenv.readthedocs.io/en/latest/) to install dependencies. 17 | 18 | The app uses a SQL database via SQLAlchemy. It was tested with PostgreSQL, but should work with other supported databases as well. 19 | 20 | ## Installation 21 | 22 | 1. Clone the repository 23 | 2. Install dependencies using `pipenv install` 24 | 25 | ## Configuration 26 | 27 | All configuration can be found in `app/config.py` file. 28 | 29 | Change at least: 30 | 31 | - `SQLALCHEMY_DATABASE_URI` for the db connection 32 | - `SECRET_KEY` to be unique to your application 33 | 34 | ## Run the application 35 | 36 | 1. Enter virtual environment using `pipenv shell` 37 | 2. Run database migrations using `flask db upgrade` 38 | 3. Run `python run.py` 39 | 4. Check to see if the application is running with `curl -XGET http://localhost:5000/ping` 40 | 41 | ### Create account 42 | 43 | ``` 44 | curl -i -H "Content-Type: application/json" -X POST -d '{"username":"user1", "password":"Password1", "email": "t@example.com"}' http://localhost:5000/api/v1/auth/signup 45 | ``` 46 | 47 | ### Log in to get JWT token 48 | 49 | ``` 50 | curl -i -H "Content-Type: application/json" -X POST -d '{"username":"user1", "password":"Password1"}' http://localhost:5000/api/v1/auth/login 51 | ``` 52 | 53 | You should get a `token` that can be used for the following two endpoints: 54 | 55 | ### Access protected endpoint 56 | 57 | Replace `XXXXX` with your token: 58 | 59 | ``` 60 | curl -i -H "Authorization: Bearer XXXXX" -H "Content-Type: application/json" -XGET http://localhost:5000/protected 61 | ``` 62 | 63 | ### Logout to invalidate the token 64 | 65 | Replace `XXXXX` with your token: 66 | 67 | ``` 68 | curl -i -H "Authorization: Bearer XXXXX" -H "Content-Type: application/json" -XPOST http://localhost:5000/api/v1/auth/logout 69 | ``` 70 | 71 | ## Run the tests 72 | 73 | 1. Enter virtual environment using `pipenv shell` 74 | 2. Run the test suite with `pytest` 75 | 76 | ## Documentation 77 | 78 | There is API specification written in OpenAPI Specification in `docs/api.yaml` 79 | 80 | ## License 81 | 82 | Various parts of the quickstart were inspired by [Bucket List API](https://github.com/jokamjohn/bucket_api). 83 | 84 | Author: [Petr Stříbný](http://stribny.name) 85 | 86 | License: The MIT License (MIT) 87 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Flask, jsonify 3 | from flask_sqlalchemy import SQLAlchemy 4 | from flask_migrate import Migrate 5 | 6 | app = Flask(__name__) 7 | 8 | app_settings = os.getenv("APP_SETTINGS", "app.config.DevelopmentConfig") 9 | app.config.from_object(app_settings) 10 | 11 | db = SQLAlchemy(app) 12 | 13 | migrate = Migrate(app, db) 14 | 15 | from app.auth.views import auth_api 16 | 17 | app.register_blueprint(auth_api, url_prefix="/api/v1/auth") 18 | 19 | from app.auth.helpers import auth_required 20 | 21 | from app.models import User, BlacklistToken 22 | 23 | @app.route("/ping") 24 | def index(): 25 | return jsonify({"status": "running"}) 26 | 27 | 28 | @app.route("/protected") 29 | @auth_required 30 | def protected(): 31 | return jsonify({"message": "Protected message"}) 32 | 33 | 34 | from app.exceptions import AppError, NotFoundError 35 | 36 | 37 | @app.errorhandler(404) 38 | def custom404(error): 39 | return NotFoundError().to_api_response() 40 | 41 | 42 | @app.errorhandler(Exception) 43 | def handle_exception(exception): 44 | return AppError().to_api_response() 45 | 46 | 47 | @app.errorhandler(AppError) 48 | def handle_application_error(exception): 49 | return exception.to_api_response() 50 | -------------------------------------------------------------------------------- /app/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stribny/flask-api-quickstart/dfe35c24d2ffcf952280c63d1ce5ae22ef69c188/app/auth/__init__.py -------------------------------------------------------------------------------- /app/auth/exceptions.py: -------------------------------------------------------------------------------- 1 | from app.exceptions import AppError 2 | 3 | 4 | class InvalidCredentialsError(AppError): 5 | def __init__(self): 6 | AppError.__init__( 7 | self, 8 | status_code=401, 9 | error_code="INVALID_CREDENTIALS", 10 | message="Invalid username or password.", 11 | ) 12 | 13 | 14 | class InvalidTokenError(AppError): 15 | def __init__(self): 16 | AppError.__init__( 17 | self, 18 | status_code=401, 19 | error_code="INVALID_TOKEN", 20 | message="Token is invalid or missing.", 21 | ) 22 | 23 | 24 | class TokenExpiredError(AppError): 25 | def __init__(self): 26 | AppError.__init__( 27 | self, 28 | status_code=401, 29 | error_code="TOKEN_EXPIRED", 30 | message="Authentication token has expired.", 31 | ) 32 | -------------------------------------------------------------------------------- /app/auth/helpers.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from flask import request 4 | 5 | from app.auth.exceptions import InvalidTokenError, TokenExpiredError 6 | from app.auth.service import decode_auth_token, is_token_blacklisted 7 | from app.models import User 8 | 9 | 10 | def get_token_from_header(): 11 | token = None 12 | if "Authorization" in request.headers: 13 | auth_header = request.headers["Authorization"] 14 | try: 15 | token = auth_header.split(" ")[1] 16 | except IndexError: 17 | token = None 18 | return token 19 | 20 | 21 | def auth_required(f): 22 | """Decorator to require auth token on marked endpoint""" 23 | 24 | @wraps(f) 25 | def decorated_function(*args, **kwargs): 26 | token = get_token_from_header() 27 | if not token: 28 | raise InvalidTokenError() 29 | 30 | if is_token_blacklisted(token): 31 | raise TokenExpiredError() 32 | 33 | token_payload = decode_auth_token(token) 34 | 35 | current_user = User.query.filter_by(id=token_payload["sub"]).first() 36 | if not current_user: 37 | raise InvalidTokenError() 38 | 39 | return f(*args, **kwargs) 40 | 41 | return decorated_function 42 | -------------------------------------------------------------------------------- /app/auth/service.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import bcrypt 3 | import jwt 4 | from usernames import is_safe_username 5 | from validate_email import validate_email 6 | 7 | from app import app, db 8 | from app.auth.exceptions import ( 9 | InvalidCredentialsError, 10 | InvalidTokenError, 11 | TokenExpiredError, 12 | ) 13 | from app.exceptions import InvalidFieldError 14 | from app.models import BlacklistToken, User 15 | from app.utils import now 16 | 17 | ENCODING = "utf-8" 18 | 19 | 20 | def create_user(username, email, password): 21 | if not is_safe_username(username): 22 | raise InvalidFieldError( 23 | "username", "Username contains forbidden characters or is a reserved word." 24 | ) 25 | 26 | if len(username) < 5: 27 | raise InvalidFieldError( 28 | "username", "Username has to be at least 5 characters long." 29 | ) 30 | 31 | if len(password) < 8: 32 | raise InvalidFieldError( 33 | "password", "Password has to be at least 8 characters long." 34 | ) 35 | 36 | if not validate_email(email): 37 | raise InvalidFieldError("email") 38 | 39 | email_used = True if User.query.filter_by(email=email).first() else False 40 | if email_used: 41 | raise InvalidFieldError("email", "Email address is already used.") 42 | 43 | username_used = True if User.query.filter_by(username=username).first() else False 44 | if username_used: 45 | raise InvalidFieldError("username", "Username is already used.") 46 | 47 | hashed_password = hash_password(password) 48 | user = User(username, email, hashed_password, now()) 49 | db.session.add(user) 50 | db.session.commit() 51 | 52 | 53 | def login_user(username, password): 54 | """Generate a new auth token for the user""" 55 | saved_user = User.query.filter_by(username=username).first() 56 | if saved_user and check_password(password, saved_user.password): 57 | token = encode_auth_token(saved_user.id) 58 | return token 59 | else: 60 | raise InvalidCredentialsError() 61 | 62 | 63 | def hash_password(password): 64 | return bcrypt.hashpw(password.encode(ENCODING), bcrypt.gensalt()).decode(ENCODING) 65 | 66 | 67 | def check_password(password, hashed_password): 68 | return bcrypt.checkpw(password.encode(ENCODING), hashed_password.encode(ENCODING)) 69 | 70 | 71 | def encode_auth_token(user_id): 72 | """Create a token with user_id and expiration date using secret key""" 73 | exp_days = app.config.get("AUTH_TOKEN_EXPIRATION_DAYS") 74 | exp_seconds = app.config.get("AUTH_TOKEN_EXPIRATION_SECONDS") 75 | exp_date = now() + datetime.timedelta( 76 | days=exp_days, seconds=exp_seconds 77 | ) 78 | payload = {"exp": exp_date, "iat": now(), "sub": user_id} 79 | return jwt.encode(payload, app.config["SECRET_KEY"], algorithm="HS256").decode( 80 | ENCODING 81 | ) 82 | 83 | 84 | def decode_auth_token(token): 85 | """Convert token to original payload using secret key if the token is valid""" 86 | try: 87 | payload = jwt.decode(token, app.config["SECRET_KEY"], algorithms="HS256") 88 | return payload 89 | except jwt.ExpiredSignatureError as ex: 90 | raise TokenExpiredError() from ex 91 | except jwt.InvalidTokenError as ex: 92 | raise InvalidTokenError() from ex 93 | 94 | 95 | def blacklist_token(token): 96 | bl_token = BlacklistToken(token, now()) 97 | db.session.add(bl_token) 98 | db.session.commit() 99 | 100 | 101 | def is_token_blacklisted(token): 102 | bl_token = BlacklistToken.query.filter_by(token=token).first() 103 | return True if bl_token else False 104 | -------------------------------------------------------------------------------- /app/auth/views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, request 2 | 3 | from app.auth.helpers import auth_required, get_token_from_header 4 | from app.auth.service import ( 5 | blacklist_token, 6 | create_user, 7 | is_token_blacklisted, 8 | login_user, 9 | ) 10 | from app.exceptions import BadRequestError 11 | 12 | auth_api = Blueprint("auth_api", __name__) 13 | 14 | 15 | @auth_api.route("/signup", methods=["POST"]) 16 | def signup(): 17 | if not request.json: 18 | raise BadRequestError() 19 | if not "username" in request.json: 20 | raise BadRequestError("'username' field is missing.") 21 | if not "password" in request.json: 22 | raise BadRequestError("'password' field is missing.") 23 | if not "email" in request.json: 24 | raise BadRequestError("'email' field is missing.") 25 | 26 | create_user( 27 | request.json["username"], request.json["email"], request.json["password"] 28 | ) 29 | 30 | return jsonify({"success": True}) 31 | 32 | 33 | @auth_api.route("/login", methods=["POST"]) 34 | def login(): 35 | if not request.json: 36 | raise BadRequestError() 37 | if not "username" in request.json: 38 | raise BadRequestError("'username' field is missing.") 39 | if not "password" in request.json: 40 | raise BadRequestError("'password' field is missing.") 41 | 42 | token = login_user(request.json["username"], request.json["password"]) 43 | return jsonify({"token": token}) 44 | 45 | 46 | @auth_api.route("/logout", methods=["POST"]) 47 | @auth_required 48 | def logout(): 49 | token = get_token_from_header() 50 | blacklist_token(token) 51 | return jsonify({"success": True}) 52 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Config: 5 | DEBUG = True 6 | TESTING = False 7 | SECRET_KEY = "VeryVerySecretKey" 8 | SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL") 9 | SQLALCHEMY_TRACK_MODIFICATIONS = False 10 | AUTH_TOKEN_EXPIRATION_DAYS = 30 11 | AUTH_TOKEN_EXPIRATION_SECONDS = 0 12 | 13 | 14 | class DevelopmentConfig(Config): 15 | SQLALCHEMY_DATABASE_URI = ( 16 | "postgresql://postgres:postgres@localhost/flask_api_quickstart" 17 | ) 18 | 19 | 20 | class TestingConfig(Config): 21 | TESTING = True 22 | SQLALCHEMY_DATABASE_URI = ( 23 | "postgresql://postgres:postgres@localhost/flask_api_quickstart_test" 24 | ) 25 | 26 | 27 | class ProductionConfig(Config): 28 | DEBUG = False 29 | 30 | 31 | app_config = { 32 | "development": DevelopmentConfig, 33 | "testing": TestingConfig, 34 | "production": ProductionConfig, 35 | } 36 | -------------------------------------------------------------------------------- /app/exceptions.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | 3 | 4 | class AppError(Exception): 5 | """Base class for all errors. Can represent error as HTTP response for API calls""" 6 | 7 | status_code = 500 8 | error_code = "INTERNAL_ERROR" 9 | message = "Request cannot be processed at the moment." 10 | 11 | def __init__(self, status_code=None, error_code=None, message=None): 12 | Exception.__init__(self) 13 | if message is not None: 14 | self.message = message 15 | if status_code is not None: 16 | self.status_code = status_code 17 | if error_code is not None: 18 | self.error_code = error_code 19 | 20 | def to_api_response(self): 21 | response = jsonify( 22 | {"errorCode": self.error_code, "errorMessage": self.message} 23 | ) 24 | response.status_code = self.status_code 25 | return response 26 | 27 | 28 | class InvalidFieldError(AppError): 29 | def __init__(self, field_name, message=""): 30 | AppError.__init__( 31 | self, 32 | status_code=422, 33 | error_code="INVALID_FIELD", 34 | message=f"Invalid '{field_name}''. {message}", 35 | ) 36 | 37 | 38 | class BadRequestError(AppError): 39 | def __init__(self, message="Malformed request."): 40 | AppError.__init__( 41 | self, status_code=400, error_code="BAD_REQUEST", message=message 42 | ) 43 | 44 | 45 | class NotFoundError(AppError): 46 | def __init__(self, message="Requested resource not found."): 47 | AppError.__init__( 48 | self, status_code=404, error_code="NOT_FOUND", message=message 49 | ) 50 | 51 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | 3 | 4 | class User(db.Model): 5 | id = db.Column(db.Integer, primary_key=True) 6 | username = db.Column(db.String(80), unique=True) 7 | email = db.Column(db.String(254), unique=True, nullable=False) 8 | password = db.Column(db.String(256), unique=False, nullable=False) 9 | created_on = db.Column(db.DateTime(timezone=True), nullable=False) 10 | 11 | def __init__(self, username, email, password, created_on): 12 | self.username = username 13 | self.email = email 14 | self.password = password 15 | self.created_on = created_on 16 | 17 | def __repr__(self): 18 | return f"User(id={self.id},username={self.username})" 19 | 20 | 21 | class BlacklistToken(db.Model): 22 | id = db.Column(db.Integer, primary_key=True) 23 | token = db.Column(db.String(256)) 24 | blacklisted_on = db.Column(db.DateTime(timezone=True), nullable=False) 25 | 26 | def __init__(self, token, blacklisted_on): 27 | self.token = token 28 | self.blacklisted_on = blacklisted_on 29 | 30 | def __repr__(self): 31 | return f"BlacklistToken(id={self.id},token={self.token})" 32 | -------------------------------------------------------------------------------- /app/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import pytz 3 | 4 | 5 | def now(): 6 | time = datetime.datetime.utcnow() 7 | return time.replace(tzinfo=pytz.utc) 8 | 9 | 10 | def as_utc_iso(date): 11 | return date.astimezone(datetime.timezone.utc).isoformat() -------------------------------------------------------------------------------- /docs/api.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Flask API Quickstart 4 | description: API documentation 5 | version: 0.0.1 6 | paths: 7 | /ping: 8 | get: 9 | summary: Health endpoint 10 | tags: 11 | - app 12 | responses: 13 | "200": 14 | description: Application is running 15 | content: 16 | application/json: 17 | schema: 18 | type: object 19 | properties: 20 | status: 21 | type: string 22 | example: running 23 | "500": 24 | $ref: "#/components/responses/InternalError" 25 | /protected: 26 | get: 27 | summary: Sample auth-protected endpoint 28 | tags: 29 | - app 30 | security: 31 | - bearerAuth: [] 32 | responses: 33 | "200": 34 | description: Example 35 | content: 36 | application/json: 37 | schema: 38 | type: object 39 | properties: 40 | message: 41 | type: string 42 | example: Protected message 43 | "401": 44 | $ref: "#/components/responses/UnauthorizedError" 45 | "500": 46 | $ref: "#/components/responses/InternalError" 47 | "/api/v1/auth/signup": 48 | post: 49 | summary: Sign up/create user account 50 | tags: 51 | - auth 52 | requestBody: 53 | description: Signup parameters (all required) 54 | required: true 55 | content: 56 | application/json: 57 | schema: 58 | type: object 59 | properties: 60 | username: 61 | type: string 62 | minLength: 5 63 | format: https://pypi.org/project/python-usernames/ 64 | email: 65 | type: string 66 | format: email 67 | password: 68 | type: string 69 | minLength: 8 70 | example: 71 | username: username1 72 | email: mail@example.com 73 | password: Password1 74 | responses: 75 | "200": 76 | description: User created 77 | content: 78 | application/json: 79 | schema: 80 | type: object 81 | properties: 82 | success: 83 | type: boolean 84 | example: true 85 | "400": 86 | $ref: "#/components/responses/BadRequestError" 87 | "422": 88 | $ref: "#/components/responses/UnprocessableEntityError" 89 | "500": 90 | $ref: "#/components/responses/InternalError" 91 | "/api/v1/auth/login": 92 | post: 93 | summary: Log in/issue auth token 94 | tags: 95 | - auth 96 | requestBody: 97 | description: Login parameters (all required) 98 | required: true 99 | content: 100 | application/json: 101 | schema: 102 | type: object 103 | properties: 104 | username: 105 | type: string 106 | minLength: 5 107 | format: https://pypi.org/project/python-usernames/ 108 | password: 109 | type: string 110 | minLength: 8 111 | example: 112 | username: username1 113 | password: Password1 114 | responses: 115 | "200": 116 | description: Token issued 117 | content: 118 | application/json: 119 | schema: 120 | type: object 121 | properties: 122 | token: 123 | type: string 124 | example: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ 125 | "400": 126 | $ref: "#/components/responses/BadRequestError" 127 | "401": 128 | $ref: "#/components/responses/InvalidCredentialsError" 129 | "500": 130 | $ref: "#/components/responses/InternalError" 131 | "/api/v1/auth/logout": 132 | post: 133 | summary: Log out/invalidate auth token 134 | tags: 135 | - auth 136 | security: 137 | - bearerAuth: [] 138 | responses: 139 | "200": 140 | description: Token invalidated 141 | content: 142 | application/json: 143 | schema: 144 | type: object 145 | properties: 146 | success: 147 | type: boolean 148 | example: true 149 | "401": 150 | $ref: "#/components/responses/UnauthorizedError" 151 | "500": 152 | $ref: "#/components/responses/InternalError" 153 | components: 154 | securitySchemes: 155 | bearerAuth: 156 | type: http 157 | scheme: bearer 158 | bearerFormat: JWT 159 | responses: 160 | BadRequestError: 161 | description: Bad request 162 | content: 163 | application/json: 164 | schema: 165 | type: object 166 | properties: 167 | errorCode: 168 | type: string 169 | enum: [BAD_REQUEST] 170 | example: BAD_REQUEST 171 | errorMessage: 172 | type: string 173 | example: XXX field is missing. 174 | InvalidCredentialsError: 175 | description: Username or password is wrong 176 | content: 177 | application/json: 178 | schema: 179 | type: object 180 | properties: 181 | errorCode: 182 | type: string 183 | enum: [INVALID_CREDENTIALS] 184 | example: INVALID_CREDENTIALS 185 | errorMessage: 186 | type: string 187 | example: Invalid username or password. 188 | UnauthorizedError: 189 | description: Access token is missing or invalid 190 | content: 191 | application/json: 192 | schema: 193 | type: object 194 | properties: 195 | errorCode: 196 | type: string 197 | enum: [INVALID_TOKEN, TOKEN_EXPIRED] 198 | example: INVALID_TOKEN 199 | errorMessage: 200 | type: string 201 | example: Token is invalid or missing. 202 | UnprocessableEntityError: 203 | description: Unprocessable entity/Validation error 204 | content: 205 | application/json: 206 | schema: 207 | type: object 208 | properties: 209 | errorCode: 210 | type: string 211 | enum: [UNPROCESSABLE_ENTITY, INVALID_FIELD] 212 | example: INVALID_FIELD 213 | errorMessage: 214 | type: string 215 | example: Invalid 'username'. Username contains forbidden characters or is a reserved word. 216 | InternalError: 217 | description: Internal application error 218 | content: 219 | application/json: 220 | schema: 221 | type: object 222 | properties: 223 | errorCode: 224 | type: string 225 | enum: [INTERNAL_ERROR] 226 | example: INTERNAL_ERROR 227 | errorMessage: 228 | type: string 229 | example: Request cannot be processed at the moment. 230 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stribny/flask-api-quickstart/dfe35c24d2ffcf952280c63d1ce5ae22ef69c188/manage.py -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/0e24da9edb9e_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 0e24da9edb9e 4 | Revises: 5 | Create Date: 2019-05-22 15:25:27.700665 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '0e24da9edb9e' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('blacklist_token', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('token', sa.String(length=256), nullable=True), 24 | sa.Column('blacklisted_on', sa.DateTime(timezone=True), nullable=False), 25 | sa.PrimaryKeyConstraint('id') 26 | ) 27 | op.create_table('user', 28 | sa.Column('id', sa.Integer(), nullable=False), 29 | sa.Column('username', sa.String(length=80), nullable=True), 30 | sa.Column('email', sa.String(length=254), nullable=False), 31 | sa.Column('password', sa.String(length=256), nullable=False), 32 | sa.Column('created_on', sa.DateTime(timezone=True), nullable=False), 33 | sa.PrimaryKeyConstraint('id'), 34 | sa.UniqueConstraint('email'), 35 | sa.UniqueConstraint('username') 36 | ) 37 | # ### end Alembic commands ### 38 | 39 | 40 | def downgrade(): 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | op.drop_table('user') 43 | op.drop_table('blacklist_token') 44 | # ### end Alembic commands ### 45 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from app import app 3 | 4 | 5 | if __name__ == '__main__': 6 | app.run(debug=True, host='0.0.0.0') 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stribny/flask-api-quickstart/dfe35c24d2ffcf952280c63d1ce5ae22ef69c188/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app import app, db 4 | 5 | 6 | def create_app(): 7 | """Return Flask's app object with test configuration""" 8 | app.config.from_object("app.config.TestingConfig") 9 | return app 10 | 11 | 12 | def set_up(): 13 | """Create database tables according to the app models""" 14 | db.create_all() 15 | db.session.commit() 16 | 17 | 18 | def tear_down(): 19 | """Remove all tables from the database""" 20 | db.session.remove() 21 | db.drop_all() 22 | 23 | 24 | @pytest.fixture 25 | def client(): 26 | """Create Flask's test client to interact with the application""" 27 | client = create_app().test_client() 28 | set_up() 29 | yield client 30 | tear_down() 31 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def signup_user(client, username, email, password): 5 | data = dict() 6 | if username: 7 | data["username"] = username 8 | if email: 9 | data["email"] = email 10 | if password: 11 | data["password"] = password 12 | 13 | return client.post( 14 | "/api/v1/auth/signup", content_type="application/json", data=json.dumps(data) 15 | ) 16 | 17 | 18 | def login_user(client, username, password): 19 | data = dict() 20 | if username: 21 | data["username"] = username 22 | if password: 23 | data["password"] = password 24 | 25 | return client.post( 26 | "/api/v1/auth/login", content_type="application/json", data=json.dumps(data) 27 | ) 28 | 29 | 30 | def get_valid_token(client): 31 | username = "usrname1" 32 | email = "usrname1@example.com" 33 | password = "Password1" 34 | signup_user(client=client, username=username, email=email, password=password) 35 | response = login_user(client=client, username=username, password=password) 36 | data = json.loads(response.data) 37 | return data["token"] 38 | 39 | 40 | def assert_success_200(response): 41 | assert response.status_code == 200 42 | assert response.content_type == "application/json" 43 | 44 | 45 | def assert_error(response, error_code): 46 | assert response.status_code == error_code 47 | assert response.content_type == "application/json" 48 | 49 | 50 | def assert_error_invalid_token(response): 51 | assert_error(response, 401) 52 | data = json.loads(response.data) 53 | assert data["errorCode"] == "INVALID_TOKEN" 54 | 55 | 56 | def assert_error_token_expired(response): 57 | assert_error(response, 401) 58 | data = json.loads(response.data) 59 | assert data["errorCode"] == "TOKEN_EXPIRED" 60 | 61 | 62 | def assert_error_missing_field(response, field): 63 | assert_error(response, 400) 64 | data = json.loads(response.data) 65 | assert data["errorCode"] == "BAD_REQUEST" 66 | assert field in data["errorMessage"] 67 | 68 | 69 | def assert_error_invalid_field(response, field): 70 | assert_error(response, 422) 71 | data = json.loads(response.data) 72 | assert data["errorCode"] == "INVALID_FIELD" 73 | assert field in data["errorMessage"] 74 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from tests.helpers import ( 4 | assert_error, 5 | assert_error_invalid_token, 6 | assert_success_200, 7 | get_valid_token, 8 | login_user, 9 | signup_user, 10 | ) 11 | 12 | 13 | def test_ping(client): 14 | response = client.get("/ping") 15 | data = json.loads(response.data) 16 | assert_success_200(response) 17 | assert data["status"] == "running" 18 | 19 | 20 | def test_access_protected_endpoint_with_valid_token(client): 21 | token = get_valid_token(client) 22 | response = client.get( 23 | "/protected", 24 | headers=dict(Authorization="Bearer " + token), 25 | content_type="application/json", 26 | ) 27 | assert_success_200(response) 28 | data = json.loads(response.data) 29 | assert data["message"] == "Protected message" 30 | 31 | 32 | def test_access_protected_endpoint_without_token(client): 33 | response = client.get("/protected", content_type="application/json") 34 | assert_error_invalid_token(response) 35 | 36 | 37 | def test_access_protected_endpoint_without_valid_token(client): 38 | token = "djkafkldhsfhl" 39 | response = client.get( 40 | "/protected", 41 | headers=dict(Authorization="Bearer " + token), 42 | content_type="application/json", 43 | ) 44 | assert_error_invalid_token(response) 45 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from tests.helpers import ( 4 | assert_error, 5 | assert_error_invalid_field, 6 | assert_error_missing_field, 7 | assert_error_token_expired, 8 | assert_success_200, 9 | get_valid_token, 10 | login_user, 11 | signup_user, 12 | ) 13 | 14 | 15 | def test_signup_success(client): 16 | response = signup_user( 17 | client=client, 18 | username="username1", 19 | email="mail@example.com", 20 | password="Password1", 21 | ) 22 | assert_success_200(response) 23 | data = json.loads(response.data) 24 | assert data["success"] == True 25 | 26 | 27 | def test_signup_missing_username(client): 28 | response = signup_user( 29 | client=client, username=None, email="mail@example.com", password="Password1" 30 | ) 31 | assert_error_missing_field(response, "username") 32 | 33 | 34 | def test_signup_missing_email(client): 35 | response = signup_user( 36 | client=client, username="username1", email=None, password="Password1" 37 | ) 38 | assert_error_missing_field(response, "email") 39 | 40 | 41 | def test_signup_missing_password(client): 42 | response = signup_user( 43 | client=client, username="username1", email="mail@example.com", password=None 44 | ) 45 | assert_error_missing_field(response, "password") 46 | 47 | 48 | def test_signup_invalid_username_too_short(client): 49 | response = signup_user( 50 | client=client, username="adam", email="mail@example.com", password="Password1" 51 | ) 52 | assert_error_invalid_field(response, "username") 53 | 54 | 55 | def test_signup_invalid_username_forbidden_chars(client): 56 | response = signup_user( 57 | client=client, 58 | username=" -- -- -- ", 59 | email="mail@example.com", 60 | password="Password1", 61 | ) 62 | assert_error_invalid_field(response, "username") 63 | 64 | 65 | def test_signup_invalid_email(client): 66 | response = signup_user( 67 | client=client, 68 | username="username1", 69 | email="mailexample.com", 70 | password="Password1", 71 | ) 72 | assert_error_invalid_field(response, "email") 73 | 74 | 75 | def test_signup_invalid_password_too_short(client): 76 | response = signup_user( 77 | client=client, username="username1", email="mail@example.com", password="1234" 78 | ) 79 | assert_error_invalid_field(response, "password") 80 | 81 | 82 | def test_signup_username_already_used(client): 83 | username = "username1" 84 | password = "Password1" 85 | signup_user( 86 | client=client, username=username, email="email@mail.com", password=password 87 | ) 88 | response = signup_user( 89 | client=client, username=username, email="email2@mail.com", password=password 90 | ) 91 | assert_error(response, 422) 92 | data = json.loads(response.data) 93 | assert data["errorCode"] == "INVALID_FIELD" 94 | assert "Username is already used" in data["errorMessage"] 95 | 96 | 97 | def test_signup_email_already_used(client): 98 | email = "email@mail.com" 99 | password = "Password1" 100 | signup_user(client=client, username="username1", email=email, password=password) 101 | response = signup_user( 102 | client=client, username="username2", email=email, password=password 103 | ) 104 | assert_error(response, 422) 105 | data = json.loads(response.data) 106 | assert data["errorCode"] == "INVALID_FIELD" 107 | assert "Email address is already used" in data["errorMessage"] 108 | 109 | 110 | def test_login_success(client): 111 | username = "username1" 112 | email = "user1@example.com" 113 | password = "Password1" 114 | signup_user(client=client, username=username, email=email, password=password) 115 | response = login_user(client=client, username=username, password=password) 116 | assert_success_200(response) 117 | data = json.loads(response.data) 118 | assert data["token"] 119 | 120 | 121 | def test_login_bad_credentials(client): 122 | username = "username1" 123 | email = "user1@example.com" 124 | password = "Password1" 125 | signup_user(client=client, username=username, email=email, password=password) 126 | response = login_user(client=client, username=username, password="Password2") 127 | assert_error(response, 401) 128 | data = json.loads(response.data) 129 | assert data["errorCode"] == "INVALID_CREDENTIALS" 130 | assert not "token" in data 131 | 132 | 133 | def test_logout(client): 134 | token = get_valid_token(client) 135 | response = client.post( 136 | "/api/v1/auth/logout", 137 | headers=dict(Authorization="Bearer " + token), 138 | content_type="application/json", 139 | ) 140 | assert_success_200(response) 141 | response_protected = client.get( 142 | "/protected", 143 | headers=dict(Authorization="Bearer " + token), 144 | content_type="application/json", 145 | ) 146 | assert_error_token_expired(response_protected) 147 | --------------------------------------------------------------------------------