From 09e77f5e24a5423b476d27bb0bd5caf52b3517d4 Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Sun, 4 Jul 2021 13:47:16 +0200 Subject: [PATCH 01/17] Migrate discord OAuth to fastapi --- Pipfile | 3 + Pipfile.lock | 387 ++++++++++++++++-------------- api/app.py | 48 ++-- api/models/__init__.py | 5 +- api/models/token.py | 41 ++++ api/models/user.py | 113 +++++++++ api/versions/v1/routers/auth.py | 206 ++++++++++++++++ api/versions/v1/routers/router.py | 3 + api/config.py => config.py | 10 +- docker-compose.yml | 13 +- launch.py | 20 +- tests/conftest.py | 10 +- tests/test_auth.py | 99 ++++++++ 13 files changed, 721 insertions(+), 237 deletions(-) create mode 100644 api/models/token.py create mode 100644 api/models/user.py create mode 100644 api/versions/v1/routers/auth.py rename api/config.py => config.py (91%) create mode 100644 tests/test_auth.py diff --git a/Pipfile b/Pipfile index a546b23..a6ede33 100644 --- a/Pipfile +++ b/Pipfile @@ -9,6 +9,9 @@ flake8 = "*" requests = "*" pre-commit = "*" pytest-asyncio = "*" +httpx = "*" +pytest = "*" +pytest-mock = "*" [packages] pyjwt = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 7b26f4d..65805c7 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2804a90e1720edb543650fa9af7cc7c0fb2e5bbdec33239b80cab491dc33d4b7" + "sha256": "b201b3894e21451ed49de5efbe8acc9fd840f84a16e5ac0e196160953a91d2b7" }, "pipfile-spec": 6, "requires": { @@ -18,54 +18,34 @@ "default": { "aiohttp": { "hashes": [ - "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe", - "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe", - "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5", - "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8", - "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd", - "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb", - "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c", - "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87", - "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0", - "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290", - "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5", - "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287", - "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde", - "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf", - "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8", - "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16", - "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf", - "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809", - "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213", - "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f", - "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013", - "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b", - "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9", - "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5", - "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb", - "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df", - "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4", - "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439", - "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f", - "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22", - "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f", - "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5", - "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970", - "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009", - "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc", - "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a", - "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95" + "sha256:173267050501e1537293df06723bc5e719990889e2820ba3932969983892e960", + "sha256:438f1f1555c02c50894604d94944cff188fe138b46467b7fa99fdceb51ab5842", + "sha256:90bed250d1435aef33a1f8c439c5056d5d25a44fe6caf33fcafafed805bad4dc", + "sha256:93c3b14747413f38f094a60e98f55e73831f0c9a23ae7faa3dc97d8963e13021", + "sha256:a6e70a38d883185b1921d8122759661c39ade54949770394412a9e713fec6fa7", + "sha256:b5036133c1ba77ed5a70208d2a021a90b76fdf8bf523ae33dae46d4f4380d86f", + "sha256:c138451a82cdbf65cddf952941d5c7a1a2cac8ce3bc618dee8d889e5251ec7a5", + "sha256:c94770383e49f9cc5912b926364ad022a6c8a5dbf5498933ca3a5713c6daf738", + "sha256:ea26536ae06df6dac021303a0df72c79e55512070e6a304ba93ad468a3a754dc" ], "index": "pypi", - "version": "==3.7.4.post0" + "version": "==4.0.0a1" + }, + "anyio": { + "hashes": [ + "sha256:07968db9fa7c1ca5435a133dc62f988d84ef78e1d9b22814a59d1c62618afbc5", + "sha256:442678a3c7e1cdcdbc37dcfe4527aa851b1b0c9162653b516e9f509821691d50" + ], + "markers": "python_full_version >= '3.6.2'", + "version": "==3.2.1" }, "asgiref": { "hashes": [ - "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee", - "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78" + "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9", + "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214" ], "markers": "python_version >= '3.6'", - "version": "==3.3.4" + "version": "==3.4.1" }, "async-timeout": { "hashes": [ @@ -93,7 +73,7 @@ "sha256:df84f3e93cd08cb31a252510a2e7be4bb15e6dff8a06d91f94c057a305d5d55d", "sha256:f86378bbfbec7334af03bad4d5fd432149286665ecc8bfbcb7135da56b15d34b" ], - "markers": "python_full_version >= '3.5.0'", + "markers": "python_version >= '3.5'", "version": "==0.23.0" }, "attrs": { @@ -104,13 +84,20 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==21.2.0" }, + "certifi": { + "hashes": [ + "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", + "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" + ], + "version": "==2021.5.30" + }, "chardet": { "hashes": [ - "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", - "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==4.0.0" + "version": "==3.0.4" }, "click": { "hashes": [ @@ -120,20 +107,13 @@ "markers": "python_version >= '3.6'", "version": "==8.0.1" }, - "colorama": { - "hashes": [ - "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", - "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" - ], - "version": "==0.4.4" - }, "fastapi": { "hashes": [ - "sha256:39569a18914075b2f1aaa03bcb9dc96a38e0e5dabaf3972e088c9077dfffa379", - "sha256:8359e55d8412a5571c0736013d90af235d6949ec4ce978e9b63500c8f4b6f714" + "sha256:6ea2286e439c4ced7cce2b2862c25859601bf327a515c12dd6e431ef5d49d12f", + "sha256:d3e3c0ac35110efb22ee3ed28201cf32f9d11a9a0e52d7dd676cad25f5219523" ], "index": "pypi", - "version": "==0.65.2" + "version": "==0.65.3" }, "h11": { "hashes": [ @@ -143,6 +123,14 @@ "markers": "python_version >= '3.6'", "version": "==0.12.0" }, + "httpcore": { + "hashes": [ + "sha256:b0d16f0012ec88d8cc848f5a55f8a03158405f4bca02ee49bc4ca2c1fda49f3e", + "sha256:db4c0dcb8323494d01b8c6d812d80091a31e520033e7b0120883d6f52da649ff" + ], + "markers": "python_version >= '3.6'", + "version": "==0.13.6" + }, "httptools": { "hashes": [ "sha256:01b392a166adcc8bc2f526a939a8aabf89fe079243e1543fd0e7dc1b58d737cb", @@ -163,56 +151,44 @@ ], "version": "==0.2.0" }, + "httpx": { + "hashes": [ + "sha256:979afafecb7d22a1d10340bafb403cf2cb75aff214426ff206521fc79d26408c", + "sha256:9f99c15d33642d38bce8405df088c1c4cfd940284b4290cacbfb02e64f4877c6" + ], + "index": "pypi", + "version": "==0.18.2" + }, "idna": { "hashes": [ "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" ], - "markers": "python_full_version >= '3.5.0'", + "markers": "python_version >= '3.5'", "version": "==3.2" }, "multidict": { "hashes": [ - "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a", - "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93", - "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632", - "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656", - "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79", - "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7", - "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d", - "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5", - "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224", - "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26", - "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea", - "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348", - "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6", - "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76", - "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1", - "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f", - "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952", - "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a", - "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37", - "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9", - "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359", - "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8", - "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da", - "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3", - "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d", - "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf", - "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841", - "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d", - "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93", - "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f", - "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647", - "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635", - "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456", - "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda", - "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5", - "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", - "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" - ], - "markers": "python_version >= '3.6'", - "version": "==5.1.0" + "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a", + "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000", + "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2", + "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507", + "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5", + "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7", + "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d", + "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463", + "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19", + "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3", + "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b", + "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c", + "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87", + "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7", + "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430", + "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255", + "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d" + ], + "markers": "python_version >= '3.5'", + "version": "==4.7.6" }, "postdb": { "hashes": [ @@ -260,10 +236,10 @@ }, "python-dotenv": { "hashes": [ - "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544", - "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f" + "sha256:dd8fe852847f4fbfadabf6183ddd4c824a9651f02d51714fa075c95561959c7d", + "sha256:effaac3c1e58d89b3ccb4d04a40dc7ad6e0275fda25fd75ae9d323e2465e202d" ], - "version": "==0.17.1" + "version": "==0.18.0" }, "pyyaml": { "hashes": [ @@ -299,6 +275,24 @@ ], "version": "==5.4.1" }, + "rfc3986": { + "extras": [ + "idna2008" + ], + "hashes": [ + "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", + "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" + ], + "version": "==1.5.0" + }, + "sniffio": { + "hashes": [ + "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", + "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de" + ], + "markers": "python_version >= '3.5'", + "version": "==1.2.0" + }, "starlette": { "hashes": [ "sha256:3c8e48e52736b3161e34c9f0e8153b4f32ec5d8995a3ee1d59410d92f75162ed", @@ -432,6 +426,14 @@ } }, "develop": { + "anyio": { + "hashes": [ + "sha256:07968db9fa7c1ca5435a133dc62f988d84ef78e1d9b22814a59d1c62618afbc5", + "sha256:442678a3c7e1cdcdbc37dcfe4527aa851b1b0c9162653b516e9f509821691d50" + ], + "markers": "python_full_version >= '3.6.2'", + "version": "==3.2.1" + }, "appdirs": { "hashes": [ "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", @@ -439,14 +441,6 @@ ], "version": "==1.4.4" }, - "atomicwrites": { - "hashes": [ - "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197", - "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a" - ], - "markers": "sys_platform == 'win32'", - "version": "==1.4.0" - }, "attrs": { "hashes": [ "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1", @@ -480,11 +474,11 @@ }, "chardet": { "hashes": [ - "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", - "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==4.0.0" + "version": "==3.0.4" }, "click": { "hashes": [ @@ -494,13 +488,6 @@ "markers": "python_version >= '3.6'", "version": "==8.0.1" }, - "colorama": { - "hashes": [ - "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", - "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" - ], - "version": "==0.4.4" - }, "distlib": { "hashes": [ "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736", @@ -523,6 +510,30 @@ "index": "pypi", "version": "==3.9.2" }, + "h11": { + "hashes": [ + "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", + "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042" + ], + "markers": "python_version >= '3.6'", + "version": "==0.12.0" + }, + "httpcore": { + "hashes": [ + "sha256:b0d16f0012ec88d8cc848f5a55f8a03158405f4bca02ee49bc4ca2c1fda49f3e", + "sha256:db4c0dcb8323494d01b8c6d812d80091a31e520033e7b0120883d6f52da649ff" + ], + "markers": "python_version >= '3.6'", + "version": "==0.13.6" + }, + "httpx": { + "hashes": [ + "sha256:979afafecb7d22a1d10340bafb403cf2cb75aff214426ff206521fc79d26408c", + "sha256:9f99c15d33642d38bce8405df088c1c4cfd940284b4290cacbfb02e64f4877c6" + ], + "index": "pypi", + "version": "==0.18.2" + }, "identify": { "hashes": [ "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421", @@ -536,7 +547,7 @@ "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" ], - "markers": "python_full_version >= '3.5.0'", + "markers": "python_version >= '3.5'", "version": "==3.2" }, "iniconfig": { @@ -569,11 +580,11 @@ }, "packaging": { "hashes": [ - "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", - "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" + "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7", + "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.9" + "markers": "python_version >= '3.6'", + "version": "==21.0" }, "pathspec": { "hashes": [ @@ -584,11 +595,11 @@ }, "pluggy": { "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + "sha256:265a94bf44ca13662f12fcd1b074c14d4b269a712f051b6f644ef7e705d6735f", + "sha256:467f0219e89bb5061a8429c6fc5cf055fa3983a0e68e84a1d205046306b37d9e" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.13.1" + "version": "==1.0.0.dev0" }, "pre-commit": { "hashes": [ @@ -624,18 +635,18 @@ }, "pyparsing": { "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" + "sha256:1c6409312ce2ce2997896af5756753778d5f1603666dba5587804f09ad82ed27", + "sha256:f4896b4cc085a1f8f8ae53a1a90db5a86b3825ff73eb974dffee3d9e701007f4" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.4.7" + "markers": "python_version >= '3.5'", + "version": "==3.0.0b2" }, "pytest": { "hashes": [ "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b", "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890" ], - "markers": "python_version >= '3.6'", + "index": "pypi", "version": "==6.2.4" }, "pytest-asyncio": { @@ -646,6 +657,14 @@ "index": "pypi", "version": "==0.15.1" }, + "pytest-mock": { + "hashes": [ + "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3", + "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62" + ], + "index": "pypi", + "version": "==3.6.1" + }, "pyyaml": { "hashes": [ "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", @@ -682,49 +701,45 @@ }, "regex": { "hashes": [ - "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5", - "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79", - "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31", - "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500", - "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11", - "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14", - "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3", - "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439", - "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c", - "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82", - "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711", - "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093", - "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a", - "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb", - "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8", - "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17", - "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000", - "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d", - "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480", - "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc", - "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0", - "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9", - "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765", - "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e", - "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a", - "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07", - "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f", - "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac", - "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7", - "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed", - "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968", - "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7", - "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2", - "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4", - "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87", - "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8", - "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10", - "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29", - "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605", - "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6", - "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042" - ], - "version": "==2021.4.4" + "sha256:0e46c1191b2eb293a6912269ed08b4512e7e241bbf591f97e527492e04c77e93", + "sha256:18040755606b0c21281493ec309214bd61e41a170509e5014f41d6a5a586e161", + "sha256:1806370b2bef4d4193eebe8ee59a9fd7547836a34917b7badbe6561a8594d9cb", + "sha256:1ccbd41dbee3a31e18938096510b7d4ee53aa9fce2ee3dcc8ec82ae264f6acfd", + "sha256:1d386402ae7f3c9b107ae5863f7ecccb0167762c82a687ae6526b040feaa5ac6", + "sha256:210c359e6ee5b83f7d8c529ba3c75ba405481d50f35a420609b0db827e2e3bb5", + "sha256:268fe9dd1deb4a30c8593cabd63f7a241dfdc5bd9dd0233906c718db22cdd49a", + "sha256:361be4d311ac995a8c7ad577025a3ae3a538531b1f2cf32efd8b7e5d33a13e5a", + "sha256:3f7a92e60930f8fca2623d9e326c173b7cf2c8b7e4fdcf984b75a1d2fb08114d", + "sha256:444723ebaeb7fa8125f29c01a31101a3854ac3de293e317944022ae5effa53a4", + "sha256:494d0172774dc0beeea984b94c95389143db029575f7ca908edd74469321ea99", + "sha256:4b1999ef60c45357598935c12508abf56edbbb9c380df6f336de38a6c3a294ae", + "sha256:4fc86b729ab88fe8ac3ec92287df253c64aa71560d76da5acd8a2e245839c629", + "sha256:5049d00dbb78f9d166d1c704e93934d42cce0570842bb1a61695123d6b01de09", + "sha256:56bef6b414949e2c9acf96cb5d78de8b529c7b99752619494e78dc76f99fd005", + "sha256:59845101de68fd5d3a1145df9ea022e85ecd1b49300ea68307ad4302320f6f61", + "sha256:6b8b629f93246e507287ee07e26744beaffb4c56ed520576deac8b615bd76012", + "sha256:6c72ebb72e64e9bd195cb35a9b9bbfb955fd953b295255b8ae3e4ad4a146b615", + "sha256:7743798dfb573d006f1143d745bf17efad39775a5190b347da5d83079646be56", + "sha256:78a2a885345a2d60b5e68099e877757d5ed12e46ba1e87507175f14f80892af3", + "sha256:849802379a660206277675aa5a5c327f5c910c690649535863ddf329b0ba8c87", + "sha256:8cf6728f89b071bd3ab37cb8a0e306f4de897553a0ed07442015ee65fbf53d62", + "sha256:a1b6a3f600d6aff97e3f28c34192c9ed93fee293bd96ef327b64adb51a74b2f6", + "sha256:a548bb51c4476332ce4139df8e637386730f79a92652a907d12c696b6252b64d", + "sha256:a8a5826d8a1b64e2ff9af488cc179e1a4d0f144d11ce486a9f34ea38ccedf4ef", + "sha256:b024ee43ee6b310fad5acaee23e6485b21468718cb792a9d1693eecacc3f0b7e", + "sha256:b092754c06852e8a8b022004aff56c24b06310189186805800d09313c37ce1f8", + "sha256:b1dbeef938281f240347d50f28ae53c4b046a23389cd1fc4acec5ea0eae646a1", + "sha256:bf819c5b77ff44accc9a24e31f1f7ceaaf6c960816913ed3ef8443b9d20d81b6", + "sha256:c11f2fca544b5e30a0e813023196a63b1cb9869106ef9a26e9dae28bce3e4e26", + "sha256:ce269e903b00d1ab4746793e9c50a57eec5d5388681abef074d7b9a65748fca5", + "sha256:d0cf2651a8804f6325747c7e55e3be0f90ee2848e25d6b817aa2728d263f9abb", + "sha256:e07e92935040c67f49571779d115ecb3e727016d42fb36ee0d8757db4ca12ee0", + "sha256:e80d2851109e56420b71f9702ad1646e2f0364528adbf6af85527bc61e49f394", + "sha256:ed77b97896312bc2deafe137ca2626e8b63808f5bedb944f73665c68093688a7", + "sha256:f32f47fb22c988c0b35756024b61d156e5c4011cb8004aa53d93b03323c45657", + "sha256:fdad3122b69cdabdb3da4c2a4107875913ac78dab0117fc73f988ad589c66b66" + ], + "version": "==2021.7.1" }, "requests": { "hashes": [ @@ -734,6 +749,16 @@ "index": "pypi", "version": "==2.25.1" }, + "rfc3986": { + "extras": [ + "idna2008" + ], + "hashes": [ + "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", + "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" + ], + "version": "==1.5.0" + }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", @@ -742,6 +767,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, + "sniffio": { + "hashes": [ + "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", + "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de" + ], + "markers": "python_version >= '3.5'", + "version": "==1.2.0" + }, "toml": { "hashes": [ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", @@ -752,11 +785,11 @@ }, "urllib3": { "hashes": [ - "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", - "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" + "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", + "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.5" + "version": "==1.26.6" }, "virtualenv": { "hashes": [ diff --git a/api/app.py b/api/app.py index fc524d2..15ec463 100644 --- a/api/app.py +++ b/api/app.py @@ -1,45 +1,35 @@ +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware from fastapi import FastAPI, HTTPException from utils.response import JSONResponse from api import versions import logging +import config log = logging.getLogger() -class API(FastAPI): - """FastAPI subclass to implement more API like handling.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - async def handle_http_exception(self, error: HTTPException): - """ - Returns errors as JSON instead of default HTML - Uses custom error handler if one exists. - """ - - handler = self._find_exception_handler(error=error) - - if handler is not None: - return await handler(error) - - headers = error.get_headers() - headers["Content-Type"] = "application/json" - - return JSONResponse( - headers=headers, - status_code=error.status_code, - content={"error": error.name, "message": error.description}, - ) - - -app = API() +app = FastAPI() +config.set_debug(app.debug) app.router.default_response_class = JSONResponse +origins = ["*"] # TODO: change origins later +app.add_middleware( + CORSMiddleware, + allow_methods=["*"], + allow_headers=["*"], + allow_origins=origins, + expose_headers=["Location"], +) app.include_router(versions.v1.router) -app.add_exception_handler(HTTPException, app.handle_http_exception) + +@app.exception_handler(RequestValidationError) +async def validation_handler(request, err: RequestValidationError): + return JSONResponse( + status_code=422, content={"error": "Invalid data", "data": err.errors()} + ) @app.exception_handler(500) diff --git a/api/models/__init__.py b/api/models/__init__.py index 0df14bd..f1aedc9 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -1,5 +1,8 @@ from postDB import Model from typing import List, Type +from .user import User +from .token import Token -models_ordered: List[Type[Model]] = [] + +models_ordered: List[Type[Model]] = [User, Token] diff --git a/api/models/token.py b/api/models/token.py new file mode 100644 index 0000000..0894597 --- /dev/null +++ b/api/models/token.py @@ -0,0 +1,41 @@ +from postDB import Model, Column, types + + +class Token(Model): + """ + Token class to store OAuth2 Tokens. + :param str token: The access_token sent from discord_data. + :param dict data: All data returned by discord API. + :param int user_id: The discord user this token relates to + :param :class:`datetime.datetime` expires_at: The time this access token expires. + You can still use the refresh_token to generate a new token. + """ + + user_id = Column( + types.ForeignKey("users", "id", sql_type=types.Integer(big=True)), + primary_key=True, + ) + expires_at = Column(types.DateTime) + token = Column(types.String) + data = Column(types.JSON) + + async def update(self): + """Create or update the Token instance.""" + query = """ + INSERT INTO tokens ( user_id, expires_at, token, data ) + VALUES ( $1, $2, $3, $4 ) + ON CONFLICT (user_id) DO UPDATE SET + expires_at = $2, + token = $3, + data = $4 + RETURNING * + """ + + record = await self.pool.fetchrow( + query, self.user_id, self.expires_at, self.token, self.data + ) + + for field, value in record.items(): + setattr(self, field, value) + + return self diff --git a/api/models/user.py b/api/models/user.py new file mode 100644 index 0000000..60c4d19 --- /dev/null +++ b/api/models/user.py @@ -0,0 +1,113 @@ +from postDB import Model, Column, types +from typing import Optional, Union +from datetime import datetime + +import utils + + +VALID_STATIC_FORMATS = frozenset({"jpeg", "jpg", "webp", "png"}) +VALID_AVATAR_FORMATS = VALID_STATIC_FORMATS | {"gif"} + + +class User(Model): + """ + User class based on some discord data extended to better suit our application. + Database Attributes: + Attributes stored in the `users` table. + :param int id: The users Discord ID + :param str username: The users discord username. + :param int discriminator: The users discord discriminator. + :param str avatar: The users avatar hash, could be None. + :param str type: The type of User this is. USER|APP + """ + + id = Column(types.Integer(big=True), unique=True) + # Store the ID as a BIGINT even though it's transferred as a string. + # This is due to a substantial difference in index time and storage space + username = Column(types.String(length=32), primary_key=True) + discriminator = Column(types.String(length=4), primary_key=True) + avatar = Column(types.String, nullable=True) + type = Column(types.String, default="USER") + + @classmethod + async def fetch(cls, id: Union[str, int]) -> Optional["User"]: + """Fetch a user with the given ID.""" + query = "SELECT * FROM users WHERE id = $1" + user = await cls.pool.fetchrow(query, int(id)) + + if user is not None: + user = cls(**user) + + return user + + @classmethod + async def create( + cls, + id: Union[str, int], + username: str, + discriminator: str, + avatar: str = None, + type: str = "USER", + ) -> Optional["User"]: + """ + Create a new User instance. + Returns the new instance if created. + Returns `None` if a Unique Violation occurred. + """ + query = """ + INSERT INTO users (id, username, discriminator, avatar, type) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING + RETURNING *; + """ + + record = await cls.pool.fetchrow( + query, int(id), username, discriminator, avatar, type + ) + + if record is None: + return None + + return cls(**record) + + @property + def created_at(self) -> datetime: + """Returns""" + return utils.snowflake_time(self.id) + + def is_avatar_animated(self) -> bool: + """Indicates if the user has an animated avatar.""" + return bool(self.avatar and self.avatar.startswith("a_")) + + @property + def default_avatar_url(self) -> str: + return "https://cdn.discordapp.com/embed/avatars/%s.png" % ( + int(self.discriminator) % 5 + ) + + @property + def avatar_url_as(self, *, fmt=None, static_format="webp", size=1024): + if not size & (size - 1) and size in range(16, 4097): + raise RuntimeWarning("size must be a power of 2 between 16 and 4096") + if fmt is not None and fmt not in VALID_AVATAR_FORMATS: + raise RuntimeWarning( + "format must be None or one of {}".format(VALID_AVATAR_FORMATS) + ) + + if fmt == "gif" and not self.is_avatar_animated(): + raise RuntimeWarning("non animated avatars do not support gif format") + if static_format not in VALID_STATIC_FORMATS: + raise RuntimeWarning( + "static_format must be one of {}".format(VALID_STATIC_FORMATS) + ) + + if self.avatar is None: + return self.default_avatar_url + "?size=%s" % size + + if fmt is None: + fmt = "gif" if self.is_avatar_animated() else static_format + + return ( + "https://cdn.discordapp.com/avatars" + "/{0.id}/{0.avatar}.{1}?size={2}".format(self, fmt, size) + ) diff --git a/api/versions/v1/routers/auth.py b/api/versions/v1/routers/auth.py new file mode 100644 index 0000000..8480c1a --- /dev/null +++ b/api/versions/v1/routers/auth.py @@ -0,0 +1,206 @@ +import jwt +import utils +import typing +import config +import aiohttp + +from api.models import User, Token + +from pydantic import BaseModel +from fastapi.params import Param +from fastapi import APIRouter, Request +from datetime import datetime, timedelta +from urllib.parse import quote_plus, urlparse +from fastapi.responses import RedirectResponse + +router = APIRouter(prefix="/auth") + + +DISCORD_ENDPOINT = "https://discord.com/api" +SCOPES = ["identify"] + + +class CallbackResponse(BaseModel): + token: str + exp: datetime + + +class CallbackBody(BaseModel): + code: str + callback: str + + +async def exchange_code( + *, code: str, scope: str, redirect_uri: str, grant_type: str = "authorization_code" +) -> typing.Tuple[dict, int]: + """Exchange discord oauth code for access and refresh tokens.""" + async with aiohttp.ClientSession() as session: + async with session.post( + "%s/v6/oauth2/token" % DISCORD_ENDPOINT, + data=dict( + code=code, + scope=scope, + grant_type=grant_type, + redirect_uri=redirect_uri, + client_id=config.discord_client_id(), + client_secret=config.discord_client_secret(), + ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) as response: + return await response.json(), response.status + + +async def get_user(access_token: str) -> dict: + """Coroutine to fetch User data from discord using the users `access_token`""" + async with aiohttp.ClientSession() as session: + async with session.get( + "%s/v6/users/@me" % DISCORD_ENDPOINT, + headers={"Authorization": "Bearer %s" % access_token}, + ) as response: + return await response.json() + + +def format_scopes(scopes: typing.List[str]) -> str: + """Format a list of scopes.""" + return " ".join(scopes) + + +def get_redirect(callback: str, scopes: typing.List[str]) -> str: + """Generates the correct oauth link depending on our provided arguments.""" + return ( + "{BASE}/oauth2/authorize?response_type=code" + "&client_id={client_id}" + "&scope={scopes}" + "&redirect_uri={redirect_uri}" + "&prompt=consent" + ).format( + BASE=DISCORD_ENDPOINT, + scopes=format_scopes(scopes), + redirect_uri=quote_plus(callback), + client_id=config.discord_client_id(), + ) + + +def is_valid_url(string: str) -> bool: + """Returns boolean describing if the provided string is a url""" + result = urlparse(string) + return all((result.scheme, result.netloc)) + + +@router.get( + "/discord/redirect", + tags=["auth"], + status_code=307, + responses={ + 307: {"description": "Successful Redirect", "content": {"text/html": {}}}, + 400: { + "description": "Invalid Callback url", + "content": {"application/json": {}}, + }, + }, +) +async def redirect_to_discord_oauth_portal( + request: Request, callback: str = Param(None) +): + """Redirect user to correct oauth link depending on specified domain and requested scopes.""" + callback = callback or (str(request.base_url) + "v1/auth/discord/callback") + + if isinstance(callback, list): + callback = callback[0] + + if not is_valid_url(callback): + return utils.JSONResponse( + {"error": "Bad Request", "message": "Not a well formed redirect URL."}, 400 + ) + + return RedirectResponse( + get_redirect(callback=callback, scopes=SCOPES), status_code=307 + ) + + +if config.debug(): + + @router.get( + "/discord/callback", + tags=["auth"], + response_model=CallbackResponse, + response_description="GET Discord OAuth Callback", + ) + async def get_discord_oauth_callback( + request: Request, code: str = Param(...), callback: str = Param(None) + ): + """ + Callback endpoint for finished discord authorization flow. + """ + callback = callback or (str(request.base_url) + "v1/auth/discord/callback") + return await post_discord_oauth_callback(code, callback) + + +@router.post( + "/discord/callback", + tags=["auth"], + response_model=CallbackResponse, + response_description="POST Discord OAuth Callback", +) +async def post_discord_oauth_callback(data: CallbackBody): + """ + Callback endpoint for finished discord authorization flow. + """ + if not is_valid_url(data.callback): + return utils.JSONResponse( + {"error": "Bad Request", "message": "Not a well formed redirect URL."}, 400 + ) + + access_data, status_code = await exchange_code( + code=data.code, scope=format_scopes(SCOPES), redirect_uri=data.callback + ) + + if access_data.get("error", False): + if status_code == 400: + return utils.JSONResponse( + { + "error": "Bad Request", + "message": "Discord returned 400 status.", + "data": access_data, + }, + 400, + ) + + if 200 < status_code >= 300: + return utils.JSONResponse( + { + "error": "Bad Gateway", + "message": "Discord returned non 2xx status code", + }, + 502, + ) + + expires_at = datetime.utcnow() + timedelta(seconds=access_data["expires_in"]) + expires_at = expires_at.replace(microsecond=0) + + user_data = await get_user(access_token=access_data["access_token"]) + user_data["id"] = uid = int(user_data["id"]) + + user = await User.fetch(id=uid) + + if user is None: + user = await User.create( + id=user_data["id"], + username=user_data["username"], + discriminator=user_data["discriminator"], + avatar=user_data["avatar"], + ) + + await Token( + user_id=user.id, + data=access_data, + expires_at=expires_at, + token=access_data["access_token"], + ).update() + + token = jwt.encode( + {"uid": user.id, "exp": expires_at, "iat": datetime.utcnow()}, + key=config.secret_key(), + ) + + return {"token": token, "exp": expires_at} diff --git a/api/versions/v1/routers/router.py b/api/versions/v1/routers/router.py index 3bcd91f..53ca8d9 100644 --- a/api/versions/v1/routers/router.py +++ b/api/versions/v1/routers/router.py @@ -1,3 +1,6 @@ from fastapi import APIRouter +from .auth import router as auth_router router = APIRouter(prefix="/v1") + +router.include_router(auth_router) diff --git a/api/config.py b/config.py similarity index 91% rename from api/config.py rename to config.py index a9ccacf..0a6ec84 100644 --- a/api/config.py +++ b/config.py @@ -2,17 +2,15 @@ import typing import logging -__debug = False log = logging.getLogger("Config") -def debug(): - return __debug +def debug() -> bool: + return bool(os.environ.get("DEBUG", False)) -def set_debug(value): - global __debug - __debug = value +def set_debug(value: bool): + os.environ["DEBUG"] = str(value) def postgres_uri() -> str: diff --git a/docker-compose.yml b/docker-compose.yml index 6ac26dc..12d8969 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,14 +4,14 @@ services: image: postgres:13 ports: - "127.0.0.1:7777:5432" - restart: always + restart: unless-stopped environment: POSTGRES_USER: API POSTGRES_PASSWORD: API POSTGRES_DB: API volumes: - - /var/lib/postgresql/data + - postgres-data:/var/lib/postgresql/data - ./postgres/init.sql:/docker-entrypoint-initdb.d/ api: @@ -20,13 +20,16 @@ services: dockerfile: Dockerfile ports: - "5000:5000" - restart: always + restart: unless-stopped depends_on: - - db + - postgres links: - - db + - postgres environment: SECRET_KEY: "${SECRET_KEY}" DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID}" DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET}" POSTGRES_URI: postgres://API:API@postgres:5432/API + +volumes: + postgres-data: diff --git a/launch.py b/launch.py index 3c0e693..49c85c5 100644 --- a/launch.py +++ b/launch.py @@ -1,11 +1,10 @@ -from uvicorn.supervisors import ChangeReload from uvicorn import Config, Server from typing import Any, Coroutine from postDB import Model -from api import config import logging import asyncio import asyncpg +import config import click logging.basicConfig(level=logging.INFO) @@ -64,7 +63,7 @@ async def prepare_postgres( except (ConnectionRefusedError,): log.warning( "[!] Failed attempt #%s/%s, trying again in %ss" - % (i, retries - i, interval) + % (i, retries, interval) ) if i == retries: @@ -164,11 +163,8 @@ def _dropdb(verbose: bool): @click.option("-h", "--host", default="127.0.0.1") @click.option("-d", "--debug", default=False, is_flag=True) @click.option("-i", "--initdb", default=False, is_flag=True) -@click.option("-r", "--reload", default=False, is_flag=True) @click.option("-v", "--verbose", default=False, is_flag=True) -def runserver( - host: str, port: str, debug: bool, initdb: bool, verbose: bool, reload: bool -): +def runserver(host: str, port: str, debug: bool, initdb: bool, verbose: bool): """ Run the Quart app. @@ -190,20 +186,14 @@ def runserver( ): exit(1) # Connecting to our postgres server failed. - server_config = Config( - "api.app:app", reload=reload, host=host, port=port, debug=debug - ) + server_config = Config("api.app:app", host=host, port=port, debug=debug) server = Server(config=server_config) async def worker(): if initdb: await safe_create_tables(verbose=verbose) - if reload: - sock = config.bind_socket() - ChangeReload(config, target=server.run, sockets=[sock]).run() - else: - await server.serve() + await server.serve() run_async(worker()) diff --git a/tests/conftest.py b/tests/conftest.py index 766207a..aba356d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ from launch import prepare_postgres, safe_create_tables, delete_tables -from api import config +import config -from fastapi.testclient import TestClient +from httpx import AsyncClient from postDB import Model import asyncio import pytest @@ -17,10 +17,12 @@ def event_loop(): @pytest.fixture(scope="session") -def app(event_loop) -> TestClient: +async def app(event_loop: asyncio.AbstractEventLoop) -> AsyncClient: from api import app - return TestClient(app) + client = AsyncClient(app=app, base_url="http://127.0.0.1") + yield client + await client.aclose() @pytest.fixture(scope="session") diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..9c4af46 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,99 @@ +import pytest +from httpx import AsyncClient +from pytest_mock import MockerFixture +from api.versions.v1.routers.auth import get_redirect, SCOPES + + +@pytest.mark.asyncio +async def test_redirect_default_code(app: AsyncClient): + res = await app.get("/v1/auth/discord/redirect", allow_redirects=False) + assert res.status_code == 307 + + +@pytest.mark.asyncio +async def test_redirect_default_url(app: AsyncClient): + res = await app.get("/v1/auth/discord/redirect") + assert str(res.url) == get_redirect( + callback="http://127.0.0.1/v1/auth/discord/callback", + scopes=SCOPES, + ) + + +@pytest.mark.asyncio +async def test_redirect_invalid_callback(app: AsyncClient): + res = await app.get("/v1/auth/discord/redirect?callback=okand") + assert res.json() == { + "error": "Bad Request", + "message": "Not a well formed redirect URL.", + } + + +@pytest.mark.asyncio +async def test_redirect_valid_callback_url(app: AsyncClient): + res = await app.get("/v1/auth/discord/redirect?callback=https://twt.gg") + assert str(res.url) == get_redirect( + callback="https://twt.gg", + scopes=SCOPES, + ) + + +@pytest.mark.asyncio +async def test_callback_discord_error(app: AsyncClient, mocker: MockerFixture): + async def exchange_code(**kwargs): + return {"error": "internal server error"}, 500 + + mocker.patch("api.versions.v1.routers.auth.exchange_code", new=exchange_code) + + res = await app.post( + "/v1/auth/discord/callback", + json={"code": "okand", "callback": "https://twt.gg"}, + ) + + assert res.status_code == 502 + + +@pytest.mark.asyncio +async def test_callback_invalid_code(app: AsyncClient, mocker: MockerFixture): + async def exchange_code(**kwargs): + return {"error": 'invalid "code" in request'}, 400 + + mocker.patch("api.versions.v1.routers.auth.exchange_code", new=exchange_code) + res = await app.post( + "/v1/auth/discord/callback", + json={"code": "invalid", "callback": "https://twt.gg"}, + ) + + assert res.json() == { + "error": "Bad Request", + "data": (await exchange_code())[0], + "message": "Discord returned 400 status.", + } + + +@pytest.mark.asyncio +@pytest.mark.db +async def test_callback_success(app: AsyncClient, db, mocker: MockerFixture): + async def exchange_code(**kwargs): + return { + "expires_in": 69420, + "access_token": "super_doper_secret_token", + "refresh_token": "super_doper_doper_secret_token", + }, 200 + + async def get_user(**kwargs): + return { + "username": "M7MD", + "discriminator": "1701", + "id": 601173582516584602, + "avatar": "135fa48ba8f26417c4b9818ae2e37aa0", + } + + mocker.patch("api.versions.v1.routers.auth.get_user", new=get_user) + mocker.patch("api.versions.v1.routers.auth.exchange_code", new=exchange_code) + + res = await app.post( + "/v1/auth/discord/callback", + json={"code": "invalid", "callback": "https://twt.gg"}, + ) + + assert res.status_code == 200 From cd51e2d23630ef7ad684ce708d58731b9cfe0289 Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Sun, 4 Jul 2021 13:58:06 +0200 Subject: [PATCH 02/17] Whoops --- Pipfile.lock | 51 +-------------------------------------------------- 1 file changed, 1 insertion(+), 50 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 65805c7..b92c961 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b201b3894e21451ed49de5efbe8acc9fd840f84a16e5ac0e196160953a91d2b7" + "sha256": "3c6f069752dfd48e39c0b4beea3a44a4793f7631db02fbd55cfd57c8fd80910a" }, "pipfile-spec": 6, "requires": { @@ -31,14 +31,6 @@ "index": "pypi", "version": "==4.0.0a1" }, - "anyio": { - "hashes": [ - "sha256:07968db9fa7c1ca5435a133dc62f988d84ef78e1d9b22814a59d1c62618afbc5", - "sha256:442678a3c7e1cdcdbc37dcfe4527aa851b1b0c9162653b516e9f509821691d50" - ], - "markers": "python_full_version >= '3.6.2'", - "version": "==3.2.1" - }, "asgiref": { "hashes": [ "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9", @@ -84,13 +76,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==21.2.0" }, - "certifi": { - "hashes": [ - "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", - "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" - ], - "version": "==2021.5.30" - }, "chardet": { "hashes": [ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", @@ -123,14 +108,6 @@ "markers": "python_version >= '3.6'", "version": "==0.12.0" }, - "httpcore": { - "hashes": [ - "sha256:b0d16f0012ec88d8cc848f5a55f8a03158405f4bca02ee49bc4ca2c1fda49f3e", - "sha256:db4c0dcb8323494d01b8c6d812d80091a31e520033e7b0120883d6f52da649ff" - ], - "markers": "python_version >= '3.6'", - "version": "==0.13.6" - }, "httptools": { "hashes": [ "sha256:01b392a166adcc8bc2f526a939a8aabf89fe079243e1543fd0e7dc1b58d737cb", @@ -151,14 +128,6 @@ ], "version": "==0.2.0" }, - "httpx": { - "hashes": [ - "sha256:979afafecb7d22a1d10340bafb403cf2cb75aff214426ff206521fc79d26408c", - "sha256:9f99c15d33642d38bce8405df088c1c4cfd940284b4290cacbfb02e64f4877c6" - ], - "index": "pypi", - "version": "==0.18.2" - }, "idna": { "hashes": [ "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", @@ -275,24 +244,6 @@ ], "version": "==5.4.1" }, - "rfc3986": { - "extras": [ - "idna2008" - ], - "hashes": [ - "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", - "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97" - ], - "version": "==1.5.0" - }, - "sniffio": { - "hashes": [ - "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", - "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de" - ], - "markers": "python_version >= '3.5'", - "version": "==1.2.0" - }, "starlette": { "hashes": [ "sha256:3c8e48e52736b3161e34c9f0e8153b4f32ec5d8995a3ee1d59410d92f75162ed", From 89f574d05c14358cfb3535d356a906def2e25728 Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Sun, 4 Jul 2021 14:09:05 +0200 Subject: [PATCH 03/17] Fix workflow --- .github/workflows/lint-and-test.yml | 12 +++++------- config.py | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 72dc704..1fbb1da 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -3,11 +3,10 @@ name: Lint & Test on: push: branches: - - '**' + - "**" pull_request: branches: - - '**' - + - "**" jobs: lint-and-test: @@ -29,7 +28,6 @@ jobs: # needed because the postgres container does not provide a healthcheck options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - name: Checkout repository uses: actions/checkout@v2 @@ -58,13 +56,13 @@ jobs: # ::error file={filename},line={line},col={col}::{message} - name: Run flake8 run: "flake8 \ - --format='::error file=%(path)s,line=%(row)d,col=%(col)d::\ - [flake8] %(code)s: %(text)s'" + --format='::error file=%(path)s,line=%(row)d,col=%(col)d::\ + [flake8] %(code)s: %(text)s'" - name: Run pytest run: | pytest env: - TEST_DB_URI: postgresql://postgres:postgres@localhost:5432/api + TEST_POSTGRES_URI: postgresql://postgres:postgres@localhost:5432/api # This isn't the real SECRET_KEY but the one used for testing SECRET_KEY: nqk8umrpc4f968_2%jz_%r-r2o@v4!21#%)h&-s_7qm150=o@6 diff --git a/config.py b/config.py index 0a6ec84..b888810 100644 --- a/config.py +++ b/config.py @@ -58,6 +58,6 @@ def test_postgres_uri() -> typing.Optional[str]: value = os.environ.get("TEST_POSTGRES_URI", "") if not value: - log.warning('Optional environment variable "TEST_DB_URI" is missing') + log.warning('Optional environment variable "TEST_POSTGRES_URI" is missing') return value From 5086ac2cb24f0b3444c3d68404e464500c7fb7dd Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Sun, 4 Jul 2021 14:13:30 +0200 Subject: [PATCH 04/17] Update `User` Model --- api/models/user.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/api/models/user.py b/api/models/user.py index 60c4d19..45e866a 100644 --- a/api/models/user.py +++ b/api/models/user.py @@ -18,7 +18,7 @@ class User(Model): :param str username: The users discord username. :param int discriminator: The users discord discriminator. :param str avatar: The users avatar hash, could be None. - :param str type: The type of User this is. USER|APP + :param str app: Is this user an `App` ? """ id = Column(types.Integer(big=True), unique=True) @@ -27,7 +27,7 @@ class User(Model): username = Column(types.String(length=32), primary_key=True) discriminator = Column(types.String(length=4), primary_key=True) avatar = Column(types.String, nullable=True) - type = Column(types.String, default="USER") + app = Column(types.Boolean, default=False) @classmethod async def fetch(cls, id: Union[str, int]) -> Optional["User"]: @@ -47,7 +47,7 @@ async def create( username: str, discriminator: str, avatar: str = None, - type: str = "USER", + app: bool = False, ) -> Optional["User"]: """ Create a new User instance. @@ -55,14 +55,14 @@ async def create( Returns `None` if a Unique Violation occurred. """ query = """ - INSERT INTO users (id, username, discriminator, avatar, type) + INSERT INTO users (id, username, discriminator, avatar, app) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING RETURNING *; """ record = await cls.pool.fetchrow( - query, int(id), username, discriminator, avatar, type + query, int(id), username, discriminator, avatar, app ) if record is None: From 34f607864071235c434a0f1d8b6bc60cd5fc935d Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Sun, 4 Jul 2021 14:54:11 +0200 Subject: [PATCH 05/17] Remove `requests` --- Pipfile | 1 - Pipfile.lock | 26 +------------------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/Pipfile b/Pipfile index a6ede33..ea2042c 100644 --- a/Pipfile +++ b/Pipfile @@ -6,7 +6,6 @@ verify_ssl = true [dev-packages] black = "*" flake8 = "*" -requests = "*" pre-commit = "*" pytest-asyncio = "*" httpx = "*" diff --git a/Pipfile.lock b/Pipfile.lock index b92c961..1e6f443 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3c6f069752dfd48e39c0b4beea3a44a4793f7631db02fbd55cfd57c8fd80910a" + "sha256": "ed64688eff1e84ab3e094a7e177e3c4b801c155eb54addd58c05d906274679e3" }, "pipfile-spec": 6, "requires": { @@ -423,14 +423,6 @@ "markers": "python_full_version >= '3.6.1'", "version": "==3.3.0" }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==3.0.4" - }, "click": { "hashes": [ "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", @@ -692,14 +684,6 @@ ], "version": "==2021.7.1" }, - "requests": { - "hashes": [ - "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", - "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" - ], - "index": "pypi", - "version": "==2.25.1" - }, "rfc3986": { "extras": [ "idna2008" @@ -734,14 +718,6 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, - "urllib3": { - "hashes": [ - "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", - "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.6" - }, "virtualenv": { "hashes": [ "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467", From 9f1e7082dc0c97b584ba0f2a57bf103671d2f22a Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Sun, 4 Jul 2021 14:56:34 +0200 Subject: [PATCH 06/17] Update folder tree --- api/versions/v1/routers/auth/helpers.py | 65 ++++++++++++++ api/versions/v1/routers/auth/models.py | 12 +++ .../v1/routers/{auth.py => auth/routes.py} | 84 ++----------------- api/versions/v1/routers/router.py | 2 +- tests/test_auth.py | 10 +-- 5 files changed, 92 insertions(+), 81 deletions(-) create mode 100644 api/versions/v1/routers/auth/helpers.py create mode 100644 api/versions/v1/routers/auth/models.py rename api/versions/v1/routers/{auth.py => auth/routes.py} (62%) diff --git a/api/versions/v1/routers/auth/helpers.py b/api/versions/v1/routers/auth/helpers.py new file mode 100644 index 0000000..d5a6b96 --- /dev/null +++ b/api/versions/v1/routers/auth/helpers.py @@ -0,0 +1,65 @@ +import config +import typing +import aiohttp + +from urllib.parse import quote_plus, urlparse + +DISCORD_ENDPOINT = "https://discord.com/api" +SCOPES = ["identify"] + + +async def exchange_code( + *, code: str, scope: str, redirect_uri: str, grant_type: str = "authorization_code" +) -> typing.Tuple[dict, int]: + """Exchange discord oauth code for access and refresh tokens.""" + async with aiohttp.ClientSession() as session: + async with session.post( + "%s/v6/oauth2/token" % DISCORD_ENDPOINT, + data=dict( + code=code, + scope=scope, + grant_type=grant_type, + redirect_uri=redirect_uri, + client_id=config.discord_client_id(), + client_secret=config.discord_client_secret(), + ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) as response: + return await response.json(), response.status + + +async def get_user(access_token: str) -> dict: + """Coroutine to fetch User data from discord using the users `access_token`""" + async with aiohttp.ClientSession() as session: + async with session.get( + "%s/v6/users/@me" % DISCORD_ENDPOINT, + headers={"Authorization": "Bearer %s" % access_token}, + ) as response: + return await response.json() + + +def format_scopes(scopes: typing.List[str]) -> str: + """Format a list of scopes.""" + return " ".join(scopes) + + +def get_redirect(callback: str, scopes: typing.List[str]) -> str: + """Generates the correct oauth link depending on our provided arguments.""" + return ( + "{BASE}/oauth2/authorize?response_type=code" + "&client_id={client_id}" + "&scope={scopes}" + "&redirect_uri={redirect_uri}" + "&prompt=consent" + ).format( + BASE=DISCORD_ENDPOINT, + scopes=format_scopes(scopes), + redirect_uri=quote_plus(callback), + client_id=config.discord_client_id(), + ) + + +def is_valid_url(string: str) -> bool: + """Returns boolean describing if the provided string is a url""" + result = urlparse(string) + return all((result.scheme, result.netloc)) diff --git a/api/versions/v1/routers/auth/models.py b/api/versions/v1/routers/auth/models.py new file mode 100644 index 0000000..0faa5e4 --- /dev/null +++ b/api/versions/v1/routers/auth/models.py @@ -0,0 +1,12 @@ +from datetime import datetime +from pydantic import BaseModel + + +class CallbackResponse(BaseModel): + token: str + exp: datetime + + +class CallbackBody(BaseModel): + code: str + callback: str diff --git a/api/versions/v1/routers/auth.py b/api/versions/v1/routers/auth/routes.py similarity index 62% rename from api/versions/v1/routers/auth.py rename to api/versions/v1/routers/auth/routes.py index 8480c1a..f455308 100644 --- a/api/versions/v1/routers/auth.py +++ b/api/versions/v1/routers/auth/routes.py @@ -1,92 +1,26 @@ import jwt import utils -import typing import config -import aiohttp from api.models import User, Token -from pydantic import BaseModel from fastapi.params import Param from fastapi import APIRouter, Request from datetime import datetime, timedelta -from urllib.parse import quote_plus, urlparse from fastapi.responses import RedirectResponse +from .models import CallbackBody, CallbackResponse +from .helpers import ( + SCOPES, + get_user, + get_redirect, + is_valid_url, + exchange_code, + format_scopes, +) router = APIRouter(prefix="/auth") -DISCORD_ENDPOINT = "https://discord.com/api" -SCOPES = ["identify"] - - -class CallbackResponse(BaseModel): - token: str - exp: datetime - - -class CallbackBody(BaseModel): - code: str - callback: str - - -async def exchange_code( - *, code: str, scope: str, redirect_uri: str, grant_type: str = "authorization_code" -) -> typing.Tuple[dict, int]: - """Exchange discord oauth code for access and refresh tokens.""" - async with aiohttp.ClientSession() as session: - async with session.post( - "%s/v6/oauth2/token" % DISCORD_ENDPOINT, - data=dict( - code=code, - scope=scope, - grant_type=grant_type, - redirect_uri=redirect_uri, - client_id=config.discord_client_id(), - client_secret=config.discord_client_secret(), - ), - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) as response: - return await response.json(), response.status - - -async def get_user(access_token: str) -> dict: - """Coroutine to fetch User data from discord using the users `access_token`""" - async with aiohttp.ClientSession() as session: - async with session.get( - "%s/v6/users/@me" % DISCORD_ENDPOINT, - headers={"Authorization": "Bearer %s" % access_token}, - ) as response: - return await response.json() - - -def format_scopes(scopes: typing.List[str]) -> str: - """Format a list of scopes.""" - return " ".join(scopes) - - -def get_redirect(callback: str, scopes: typing.List[str]) -> str: - """Generates the correct oauth link depending on our provided arguments.""" - return ( - "{BASE}/oauth2/authorize?response_type=code" - "&client_id={client_id}" - "&scope={scopes}" - "&redirect_uri={redirect_uri}" - "&prompt=consent" - ).format( - BASE=DISCORD_ENDPOINT, - scopes=format_scopes(scopes), - redirect_uri=quote_plus(callback), - client_id=config.discord_client_id(), - ) - - -def is_valid_url(string: str) -> bool: - """Returns boolean describing if the provided string is a url""" - result = urlparse(string) - return all((result.scheme, result.netloc)) - - @router.get( "/discord/redirect", tags=["auth"], diff --git a/api/versions/v1/routers/router.py b/api/versions/v1/routers/router.py index 53ca8d9..0c9d2bb 100644 --- a/api/versions/v1/routers/router.py +++ b/api/versions/v1/routers/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from .auth import router as auth_router +from .auth.routes import router as auth_router router = APIRouter(prefix="/v1") diff --git a/tests/test_auth.py b/tests/test_auth.py index 9c4af46..9c06590 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,7 @@ import pytest from httpx import AsyncClient from pytest_mock import MockerFixture -from api.versions.v1.routers.auth import get_redirect, SCOPES +from api.versions.v1.routers.auth.helpers import get_redirect, SCOPES @pytest.mark.asyncio @@ -42,7 +42,7 @@ async def test_callback_discord_error(app: AsyncClient, mocker: MockerFixture): async def exchange_code(**kwargs): return {"error": "internal server error"}, 500 - mocker.patch("api.versions.v1.routers.auth.exchange_code", new=exchange_code) + mocker.patch("api.versions.v1.routers.auth.routes.exchange_code", new=exchange_code) res = await app.post( "/v1/auth/discord/callback", @@ -57,7 +57,7 @@ async def test_callback_invalid_code(app: AsyncClient, mocker: MockerFixture): async def exchange_code(**kwargs): return {"error": 'invalid "code" in request'}, 400 - mocker.patch("api.versions.v1.routers.auth.exchange_code", new=exchange_code) + mocker.patch("api.versions.v1.routers.auth.routes.exchange_code", new=exchange_code) res = await app.post( "/v1/auth/discord/callback", json={"code": "invalid", "callback": "https://twt.gg"}, @@ -88,8 +88,8 @@ async def get_user(**kwargs): "avatar": "135fa48ba8f26417c4b9818ae2e37aa0", } - mocker.patch("api.versions.v1.routers.auth.get_user", new=get_user) - mocker.patch("api.versions.v1.routers.auth.exchange_code", new=exchange_code) + mocker.patch("api.versions.v1.routers.auth.routes.get_user", new=get_user) + mocker.patch("api.versions.v1.routers.auth.routes.exchange_code", new=exchange_code) res = await app.post( "/v1/auth/discord/callback", From 14aba018d6b60ad46fd1cc49f8f985829ece8352 Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Wed, 7 Jul 2021 14:48:37 +0200 Subject: [PATCH 07/17] remove models --- api/models/__init__.py | 8 --- api/models/token.py | 41 --------------- api/models/user.py | 113 ----------------------------------------- 3 files changed, 162 deletions(-) delete mode 100644 api/models/__init__.py delete mode 100644 api/models/token.py delete mode 100644 api/models/user.py diff --git a/api/models/__init__.py b/api/models/__init__.py deleted file mode 100644 index f1aedc9..0000000 --- a/api/models/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from postDB import Model -from typing import List, Type - -from .user import User -from .token import Token - - -models_ordered: List[Type[Model]] = [User, Token] diff --git a/api/models/token.py b/api/models/token.py deleted file mode 100644 index 0894597..0000000 --- a/api/models/token.py +++ /dev/null @@ -1,41 +0,0 @@ -from postDB import Model, Column, types - - -class Token(Model): - """ - Token class to store OAuth2 Tokens. - :param str token: The access_token sent from discord_data. - :param dict data: All data returned by discord API. - :param int user_id: The discord user this token relates to - :param :class:`datetime.datetime` expires_at: The time this access token expires. - You can still use the refresh_token to generate a new token. - """ - - user_id = Column( - types.ForeignKey("users", "id", sql_type=types.Integer(big=True)), - primary_key=True, - ) - expires_at = Column(types.DateTime) - token = Column(types.String) - data = Column(types.JSON) - - async def update(self): - """Create or update the Token instance.""" - query = """ - INSERT INTO tokens ( user_id, expires_at, token, data ) - VALUES ( $1, $2, $3, $4 ) - ON CONFLICT (user_id) DO UPDATE SET - expires_at = $2, - token = $3, - data = $4 - RETURNING * - """ - - record = await self.pool.fetchrow( - query, self.user_id, self.expires_at, self.token, self.data - ) - - for field, value in record.items(): - setattr(self, field, value) - - return self diff --git a/api/models/user.py b/api/models/user.py deleted file mode 100644 index 45e866a..0000000 --- a/api/models/user.py +++ /dev/null @@ -1,113 +0,0 @@ -from postDB import Model, Column, types -from typing import Optional, Union -from datetime import datetime - -import utils - - -VALID_STATIC_FORMATS = frozenset({"jpeg", "jpg", "webp", "png"}) -VALID_AVATAR_FORMATS = VALID_STATIC_FORMATS | {"gif"} - - -class User(Model): - """ - User class based on some discord data extended to better suit our application. - Database Attributes: - Attributes stored in the `users` table. - :param int id: The users Discord ID - :param str username: The users discord username. - :param int discriminator: The users discord discriminator. - :param str avatar: The users avatar hash, could be None. - :param str app: Is this user an `App` ? - """ - - id = Column(types.Integer(big=True), unique=True) - # Store the ID as a BIGINT even though it's transferred as a string. - # This is due to a substantial difference in index time and storage space - username = Column(types.String(length=32), primary_key=True) - discriminator = Column(types.String(length=4), primary_key=True) - avatar = Column(types.String, nullable=True) - app = Column(types.Boolean, default=False) - - @classmethod - async def fetch(cls, id: Union[str, int]) -> Optional["User"]: - """Fetch a user with the given ID.""" - query = "SELECT * FROM users WHERE id = $1" - user = await cls.pool.fetchrow(query, int(id)) - - if user is not None: - user = cls(**user) - - return user - - @classmethod - async def create( - cls, - id: Union[str, int], - username: str, - discriminator: str, - avatar: str = None, - app: bool = False, - ) -> Optional["User"]: - """ - Create a new User instance. - Returns the new instance if created. - Returns `None` if a Unique Violation occurred. - """ - query = """ - INSERT INTO users (id, username, discriminator, avatar, app) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT DO NOTHING - RETURNING *; - """ - - record = await cls.pool.fetchrow( - query, int(id), username, discriminator, avatar, app - ) - - if record is None: - return None - - return cls(**record) - - @property - def created_at(self) -> datetime: - """Returns""" - return utils.snowflake_time(self.id) - - def is_avatar_animated(self) -> bool: - """Indicates if the user has an animated avatar.""" - return bool(self.avatar and self.avatar.startswith("a_")) - - @property - def default_avatar_url(self) -> str: - return "https://cdn.discordapp.com/embed/avatars/%s.png" % ( - int(self.discriminator) % 5 - ) - - @property - def avatar_url_as(self, *, fmt=None, static_format="webp", size=1024): - if not size & (size - 1) and size in range(16, 4097): - raise RuntimeWarning("size must be a power of 2 between 16 and 4096") - if fmt is not None and fmt not in VALID_AVATAR_FORMATS: - raise RuntimeWarning( - "format must be None or one of {}".format(VALID_AVATAR_FORMATS) - ) - - if fmt == "gif" and not self.is_avatar_animated(): - raise RuntimeWarning("non animated avatars do not support gif format") - if static_format not in VALID_STATIC_FORMATS: - raise RuntimeWarning( - "static_format must be one of {}".format(VALID_STATIC_FORMATS) - ) - - if self.avatar is None: - return self.default_avatar_url + "?size=%s" % size - - if fmt is None: - fmt = "gif" if self.is_avatar_animated() else static_format - - return ( - "https://cdn.discordapp.com/avatars" - "/{0.id}/{0.avatar}.{1}?size={2}".format(self, fmt, size) - ) From f5621a8939dcd295822a77ebc82e76400f6155f1 Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Wed, 7 Jul 2021 14:55:03 +0200 Subject: [PATCH 08/17] Add models submodule --- .gitmodules | 3 +++ api/models | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 api/models diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..8eca457 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "api/models"] + path = api/models + url = https://github.com/Tech-With-Tim/models diff --git a/api/models b/api/models new file mode 160000 index 0000000..c87b5a2 --- /dev/null +++ b/api/models @@ -0,0 +1 @@ +Subproject commit c87b5a217bffb81af901001dc9f2941c0b2f59fe From d695e09866e2b63bb6def354edcb355c92454b61 Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Wed, 7 Jul 2021 15:12:31 +0200 Subject: [PATCH 09/17] Fix submodule --- .github/workflows/lint-and-test.yml | 2 ++ api/models | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 1fbb1da..487403f 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -31,6 +31,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v2 + with: + submodules: recursive - name: Set up Python3.8 uses: actions/setup-python@v2 diff --git a/api/models b/api/models index c87b5a2..50531e1 160000 --- a/api/models +++ b/api/models @@ -1 +1 @@ -Subproject commit c87b5a217bffb81af901001dc9f2941c0b2f59fe +Subproject commit 50531e15ab5eac0db6c1d15469a396932f6f1b37 From b2f76597392890bc25661917f4430a4dc647179f Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Wed, 7 Jul 2021 16:57:53 +0200 Subject: [PATCH 10/17] fixes. --- api/versions/v1/routers/auth/__init__.py | 4 ++++ api/versions/v1/routers/auth/helpers.py | 8 +------- api/versions/v1/routers/auth/models.py | 4 ++-- api/versions/v1/routers/auth/routes.py | 24 ++++-------------------- api/versions/v1/routers/router.py | 4 ++-- 5 files changed, 13 insertions(+), 31 deletions(-) create mode 100644 api/versions/v1/routers/auth/__init__.py diff --git a/api/versions/v1/routers/auth/__init__.py b/api/versions/v1/routers/auth/__init__.py new file mode 100644 index 0000000..9bd4168 --- /dev/null +++ b/api/versions/v1/routers/auth/__init__.py @@ -0,0 +1,4 @@ +from .routes import router + + +__all__ = (router,) diff --git a/api/versions/v1/routers/auth/helpers.py b/api/versions/v1/routers/auth/helpers.py index d5a6b96..9231338 100644 --- a/api/versions/v1/routers/auth/helpers.py +++ b/api/versions/v1/routers/auth/helpers.py @@ -2,7 +2,7 @@ import typing import aiohttp -from urllib.parse import quote_plus, urlparse +from urllib.parse import quote_plus DISCORD_ENDPOINT = "https://discord.com/api" SCOPES = ["identify"] @@ -57,9 +57,3 @@ def get_redirect(callback: str, scopes: typing.List[str]) -> str: redirect_uri=quote_plus(callback), client_id=config.discord_client_id(), ) - - -def is_valid_url(string: str) -> bool: - """Returns boolean describing if the provided string is a url""" - result = urlparse(string) - return all((result.scheme, result.netloc)) diff --git a/api/versions/v1/routers/auth/models.py b/api/versions/v1/routers/auth/models.py index 0faa5e4..5adb232 100644 --- a/api/versions/v1/routers/auth/models.py +++ b/api/versions/v1/routers/auth/models.py @@ -1,5 +1,5 @@ from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, HttpUrl class CallbackResponse(BaseModel): @@ -9,4 +9,4 @@ class CallbackResponse(BaseModel): class CallbackBody(BaseModel): code: str - callback: str + callback: HttpUrl diff --git a/api/versions/v1/routers/auth/routes.py b/api/versions/v1/routers/auth/routes.py index f455308..3ddf208 100644 --- a/api/versions/v1/routers/auth/routes.py +++ b/api/versions/v1/routers/auth/routes.py @@ -4,7 +4,7 @@ from api.models import User, Token -from fastapi.params import Param +from pydantic import HttpUrl from fastapi import APIRouter, Request from datetime import datetime, timedelta from fastapi.responses import RedirectResponse @@ -13,7 +13,6 @@ SCOPES, get_user, get_redirect, - is_valid_url, exchange_code, format_scopes, ) @@ -33,20 +32,10 @@ }, }, ) -async def redirect_to_discord_oauth_portal( - request: Request, callback: str = Param(None) -): +async def redirect_to_discord_oauth_portal(request: Request, callback: HttpUrl = None): """Redirect user to correct oauth link depending on specified domain and requested scopes.""" callback = callback or (str(request.base_url) + "v1/auth/discord/callback") - if isinstance(callback, list): - callback = callback[0] - - if not is_valid_url(callback): - return utils.JSONResponse( - {"error": "Bad Request", "message": "Not a well formed redirect URL."}, 400 - ) - return RedirectResponse( get_redirect(callback=callback, scopes=SCOPES), status_code=307 ) @@ -61,7 +50,7 @@ async def redirect_to_discord_oauth_portal( response_description="GET Discord OAuth Callback", ) async def get_discord_oauth_callback( - request: Request, code: str = Param(...), callback: str = Param(None) + request: Request, code: str, callback: HttpUrl = None ): """ Callback endpoint for finished discord authorization flow. @@ -80,11 +69,6 @@ async def post_discord_oauth_callback(data: CallbackBody): """ Callback endpoint for finished discord authorization flow. """ - if not is_valid_url(data.callback): - return utils.JSONResponse( - {"error": "Bad Request", "message": "Not a well formed redirect URL."}, 400 - ) - access_data, status_code = await exchange_code( code=data.code, scope=format_scopes(SCOPES), redirect_uri=data.callback ) @@ -100,7 +84,7 @@ async def post_discord_oauth_callback(data: CallbackBody): 400, ) - if 200 < status_code >= 300: + if status_code < 200 or status_code >= 300: return utils.JSONResponse( { "error": "Bad Gateway", diff --git a/api/versions/v1/routers/router.py b/api/versions/v1/routers/router.py index 0c9d2bb..f9810cc 100644 --- a/api/versions/v1/routers/router.py +++ b/api/versions/v1/routers/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from .auth.routes import router as auth_router +from . import auth router = APIRouter(prefix="/v1") -router.include_router(auth_router) +router.include_router(auth.router) From af593e5ccf9359d3660f22fccf8f44ad10df5c60 Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Wed, 7 Jul 2021 17:52:24 +0200 Subject: [PATCH 11/17] Fix Dockerfiles --- Dockerfile | 3 +-- prod.Dockerfile | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7f83aab..3a5bb67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,8 @@ WORKDIR /app RUN apt-get update && apt-get install gcc -y -COPY Pipfile Pipfile.lock ./ - RUN pip install pipenv +COPY Pipfile Pipfile.lock ./ RUN pipenv install --deploy --system ADD . /app diff --git a/prod.Dockerfile b/prod.Dockerfile index 6aee172..e89b62b 100644 --- a/prod.Dockerfile +++ b/prod.Dockerfile @@ -11,9 +11,8 @@ WORKDIR /app RUN apt-get update && apt-get install gcc -y -COPY Pipfile Pipfile.lock ./ - RUN pip install pipenv +COPY Pipfile Pipfile.lock ./ RUN pipenv install --deploy --system ADD . /app From 67b8ffd91c3faa495dc25e01cac29a507bfae18c Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Wed, 7 Jul 2021 17:57:05 +0200 Subject: [PATCH 12/17] should be good now! --- Pipfile.lock | 96 ++++++++++++++------------ api/versions/v1/routers/auth/routes.py | 7 -- config.py | 8 ++- tests/test_auth.py | 5 +- 4 files changed, 57 insertions(+), 59 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 1e6f443..7de7222 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -65,7 +65,7 @@ "sha256:df84f3e93cd08cb31a252510a2e7be4bb15e6dff8a06d91f94c057a305d5d55d", "sha256:f86378bbfbec7334af03bad4d5fd432149286665ecc8bfbcb7135da56b15d34b" ], - "markers": "python_version >= '3.5'", + "markers": "python_full_version >= '3.5.0'", "version": "==0.23.0" }, "attrs": { @@ -94,11 +94,11 @@ }, "fastapi": { "hashes": [ - "sha256:6ea2286e439c4ced7cce2b2862c25859601bf327a515c12dd6e431ef5d49d12f", - "sha256:d3e3c0ac35110efb22ee3ed28201cf32f9d11a9a0e52d7dd676cad25f5219523" + "sha256:6ea4225448786f3d6fae737713789f87631a7455f65580de0a4a2e50471060d9", + "sha256:85d8aee8c3c46171f4cb7bb3651425a42c07cb9183345d100ef55d88ca2ce15f" ], "index": "pypi", - "version": "==0.65.3" + "version": "==0.66.0" }, "h11": { "hashes": [ @@ -133,7 +133,7 @@ "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" ], - "markers": "python_version >= '3.5'", + "markers": "python_full_version >= '3.5.0'", "version": "==3.2" }, "multidict": { @@ -156,7 +156,7 @@ "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255", "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d" ], - "markers": "python_version >= '3.5'", + "markers": "python_full_version >= '3.5.0'", "version": "==4.7.6" }, "postdb": { @@ -490,7 +490,7 @@ "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a", "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3" ], - "markers": "python_version >= '3.5'", + "markers": "python_full_version >= '3.5.0'", "version": "==3.2" }, "iniconfig": { @@ -644,45 +644,49 @@ }, "regex": { "hashes": [ - "sha256:0e46c1191b2eb293a6912269ed08b4512e7e241bbf591f97e527492e04c77e93", - "sha256:18040755606b0c21281493ec309214bd61e41a170509e5014f41d6a5a586e161", - "sha256:1806370b2bef4d4193eebe8ee59a9fd7547836a34917b7badbe6561a8594d9cb", - "sha256:1ccbd41dbee3a31e18938096510b7d4ee53aa9fce2ee3dcc8ec82ae264f6acfd", - "sha256:1d386402ae7f3c9b107ae5863f7ecccb0167762c82a687ae6526b040feaa5ac6", - "sha256:210c359e6ee5b83f7d8c529ba3c75ba405481d50f35a420609b0db827e2e3bb5", - "sha256:268fe9dd1deb4a30c8593cabd63f7a241dfdc5bd9dd0233906c718db22cdd49a", - "sha256:361be4d311ac995a8c7ad577025a3ae3a538531b1f2cf32efd8b7e5d33a13e5a", - "sha256:3f7a92e60930f8fca2623d9e326c173b7cf2c8b7e4fdcf984b75a1d2fb08114d", - "sha256:444723ebaeb7fa8125f29c01a31101a3854ac3de293e317944022ae5effa53a4", - "sha256:494d0172774dc0beeea984b94c95389143db029575f7ca908edd74469321ea99", - "sha256:4b1999ef60c45357598935c12508abf56edbbb9c380df6f336de38a6c3a294ae", - "sha256:4fc86b729ab88fe8ac3ec92287df253c64aa71560d76da5acd8a2e245839c629", - "sha256:5049d00dbb78f9d166d1c704e93934d42cce0570842bb1a61695123d6b01de09", - "sha256:56bef6b414949e2c9acf96cb5d78de8b529c7b99752619494e78dc76f99fd005", - "sha256:59845101de68fd5d3a1145df9ea022e85ecd1b49300ea68307ad4302320f6f61", - "sha256:6b8b629f93246e507287ee07e26744beaffb4c56ed520576deac8b615bd76012", - "sha256:6c72ebb72e64e9bd195cb35a9b9bbfb955fd953b295255b8ae3e4ad4a146b615", - "sha256:7743798dfb573d006f1143d745bf17efad39775a5190b347da5d83079646be56", - "sha256:78a2a885345a2d60b5e68099e877757d5ed12e46ba1e87507175f14f80892af3", - "sha256:849802379a660206277675aa5a5c327f5c910c690649535863ddf329b0ba8c87", - "sha256:8cf6728f89b071bd3ab37cb8a0e306f4de897553a0ed07442015ee65fbf53d62", - "sha256:a1b6a3f600d6aff97e3f28c34192c9ed93fee293bd96ef327b64adb51a74b2f6", - "sha256:a548bb51c4476332ce4139df8e637386730f79a92652a907d12c696b6252b64d", - "sha256:a8a5826d8a1b64e2ff9af488cc179e1a4d0f144d11ce486a9f34ea38ccedf4ef", - "sha256:b024ee43ee6b310fad5acaee23e6485b21468718cb792a9d1693eecacc3f0b7e", - "sha256:b092754c06852e8a8b022004aff56c24b06310189186805800d09313c37ce1f8", - "sha256:b1dbeef938281f240347d50f28ae53c4b046a23389cd1fc4acec5ea0eae646a1", - "sha256:bf819c5b77ff44accc9a24e31f1f7ceaaf6c960816913ed3ef8443b9d20d81b6", - "sha256:c11f2fca544b5e30a0e813023196a63b1cb9869106ef9a26e9dae28bce3e4e26", - "sha256:ce269e903b00d1ab4746793e9c50a57eec5d5388681abef074d7b9a65748fca5", - "sha256:d0cf2651a8804f6325747c7e55e3be0f90ee2848e25d6b817aa2728d263f9abb", - "sha256:e07e92935040c67f49571779d115ecb3e727016d42fb36ee0d8757db4ca12ee0", - "sha256:e80d2851109e56420b71f9702ad1646e2f0364528adbf6af85527bc61e49f394", - "sha256:ed77b97896312bc2deafe137ca2626e8b63808f5bedb944f73665c68093688a7", - "sha256:f32f47fb22c988c0b35756024b61d156e5c4011cb8004aa53d93b03323c45657", - "sha256:fdad3122b69cdabdb3da4c2a4107875913ac78dab0117fc73f988ad589c66b66" - ], - "version": "==2021.7.1" + "sha256:0eb2c6e0fcec5e0f1d3bcc1133556563222a2ffd2211945d7b1480c1b1a42a6f", + "sha256:15dddb19823f5147e7517bb12635b3c82e6f2a3a6b696cc3e321522e8b9308ad", + "sha256:173bc44ff95bc1e96398c38f3629d86fa72e539c79900283afa895694229fe6a", + "sha256:1c78780bf46d620ff4fff40728f98b8afd8b8e35c3efd638c7df67be2d5cddbf", + "sha256:2366fe0479ca0e9afa534174faa2beae87847d208d457d200183f28c74eaea59", + "sha256:2bceeb491b38225b1fee4517107b8491ba54fba77cf22a12e996d96a3c55613d", + "sha256:2ddeabc7652024803666ea09f32dd1ed40a0579b6fbb2a213eba590683025895", + "sha256:2fe5e71e11a54e3355fa272137d521a40aace5d937d08b494bed4529964c19c4", + "sha256:319eb2a8d0888fa6f1d9177705f341bc9455a2c8aca130016e52c7fe8d6c37a3", + "sha256:3f5716923d3d0bfb27048242a6e0f14eecdb2e2a7fac47eda1d055288595f222", + "sha256:422dec1e7cbb2efbbe50e3f1de36b82906def93ed48da12d1714cabcd993d7f0", + "sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c", + "sha256:4f64fc59fd5b10557f6cd0937e1597af022ad9b27d454e182485f1db3008f417", + "sha256:564a4c8a29435d1f2256ba247a0315325ea63335508ad8ed938a4f14c4116a5d", + "sha256:59506c6e8bd9306cd8a41511e32d16d5d1194110b8cfe5a11d102d8b63cf945d", + "sha256:598c0a79b4b851b922f504f9f39a863d83ebdfff787261a5ed061c21e67dd761", + "sha256:59c00bb8dd8775473cbfb967925ad2c3ecc8886b3b2d0c90a8e2707e06c743f0", + "sha256:6110bab7eab6566492618540c70edd4d2a18f40ca1d51d704f1d81c52d245026", + "sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854", + "sha256:791aa1b300e5b6e5d597c37c346fb4d66422178566bbb426dd87eaae475053fb", + "sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d", + "sha256:875c355360d0f8d3d827e462b29ea7682bf52327d500a4f837e934e9e4656068", + "sha256:89e5528803566af4df368df2d6f503c84fbfb8249e6631c7b025fe23e6bd0cde", + "sha256:99d8ab206a5270c1002bfcf25c51bf329ca951e5a169f3b43214fdda1f0b5f0d", + "sha256:9a854b916806c7e3b40e6616ac9e85d3cdb7649d9e6590653deb5b341a736cec", + "sha256:b85ac458354165405c8a84725de7bbd07b00d9f72c31a60ffbf96bb38d3e25fa", + "sha256:bc84fb254a875a9f66616ed4538542fb7965db6356f3df571d783f7c8d256edd", + "sha256:c92831dac113a6e0ab28bc98f33781383fe294df1a2c3dfd1e850114da35fd5b", + "sha256:cbe23b323988a04c3e5b0c387fe3f8f363bf06c0680daf775875d979e376bd26", + "sha256:ccb3d2190476d00414aab36cca453e4596e8f70a206e2aa8db3d495a109153d2", + "sha256:d8bbce0c96462dbceaa7ac4a7dfbbee92745b801b24bce10a98d2f2b1ea9432f", + "sha256:db2b7df831c3187a37f3bb80ec095f249fa276dbe09abd3d35297fc250385694", + "sha256:e586f448df2bbc37dfadccdb7ccd125c62b4348cb90c10840d695592aa1b29e0", + "sha256:e5983c19d0beb6af88cb4d47afb92d96751fb3fa1784d8785b1cdf14c6519407", + "sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874", + "sha256:eaf58b9e30e0e546cdc3ac06cf9165a1ca5b3de8221e9df679416ca667972035", + "sha256:ed693137a9187052fc46eedfafdcb74e09917166362af4cc4fddc3b31560e93d", + "sha256:edd1a68f79b89b0c57339bce297ad5d5ffcc6ae7e1afdb10f1947706ed066c9c", + "sha256:f080248b3e029d052bf74a897b9d74cfb7643537fbde97fe8225a6467fb559b5", + "sha256:f9392a4555f3e4cb45310a65b403d86b589adc773898c25a39184b1ba4db8985", + "sha256:f98dc35ab9a749276f1a4a38ab3e0e2ba1662ce710f6530f5b0a6656f1c32b58" + ], + "version": "==2021.7.6" }, "rfc3986": { "extras": [ diff --git a/api/versions/v1/routers/auth/routes.py b/api/versions/v1/routers/auth/routes.py index 3ddf208..ccf3bd9 100644 --- a/api/versions/v1/routers/auth/routes.py +++ b/api/versions/v1/routers/auth/routes.py @@ -24,13 +24,6 @@ "/discord/redirect", tags=["auth"], status_code=307, - responses={ - 307: {"description": "Successful Redirect", "content": {"text/html": {}}}, - 400: { - "description": "Invalid Callback url", - "content": {"application/json": {}}, - }, - }, ) async def redirect_to_discord_oauth_portal(request: Request, callback: HttpUrl = None): """Redirect user to correct oauth link depending on specified domain and requested scopes.""" diff --git a/config.py b/config.py index b888810..a01ef79 100644 --- a/config.py +++ b/config.py @@ -2,15 +2,19 @@ import typing import logging + log = logging.getLogger("Config") +__debug = False + def debug() -> bool: - return bool(os.environ.get("DEBUG", False)) + return __debug def set_debug(value: bool): - os.environ["DEBUG"] = str(value) + global __debug + __debug = value def postgres_uri() -> str: diff --git a/tests/test_auth.py b/tests/test_auth.py index 9c06590..d2bc106 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -22,10 +22,7 @@ async def test_redirect_default_url(app: AsyncClient): @pytest.mark.asyncio async def test_redirect_invalid_callback(app: AsyncClient): res = await app.get("/v1/auth/discord/redirect?callback=okand") - assert res.json() == { - "error": "Bad Request", - "message": "Not a well formed redirect URL.", - } + assert res.status_code == 422 @pytest.mark.asyncio From cf60d66819676e446c0d961a844a0b9e7920c49a Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Sun, 1 Aug 2021 17:49:27 +0200 Subject: [PATCH 13/17] hmmm --- api/__init__.py | 3 +- api/app.py | 23 ++++++++++++-- api/http_session.py | 6 ++++ api/versions/v1/routers/auth/helpers.py | 40 ++++++++++++------------- docker-compose.yml | 3 -- tests/conftest.py | 7 +++-- tests/test_auth.py | 30 +++++++++++-------- 7 files changed, 69 insertions(+), 43 deletions(-) create mode 100644 api/http_session.py diff --git a/api/__init__.py b/api/__init__.py index 0ccbfdd..32dfc0e 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,3 +1,4 @@ from .app import app +from .http_session import session -__all__ = ("app",) +__all__ = ("app", "session") diff --git a/api/app.py b/api/app.py index 15ec463..94b3cb5 100644 --- a/api/app.py +++ b/api/app.py @@ -2,16 +2,16 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi import FastAPI, HTTPException from utils.response import JSONResponse +from aiohttp import ClientSession from api import versions import logging -import config log = logging.getLogger() app = FastAPI() -config.set_debug(app.debug) +app.router.prefix = "/api" app.router.default_response_class = JSONResponse origins = ["*"] # TODO: change origins later @@ -25,6 +25,25 @@ app.include_router(versions.v1.router) +@app.on_event("startup") +async def on_startup(): + """Creates a ClientSession to be used app-wide.""" + from api import session + + if session is None or session.closed: + session = ClientSession() + log.info("Set http_session.") + + +@app.on_event("shutdown") +async def on_shutdown(): + """Closes the app-wide ClientSession""" + from api import session + + if session is not None and not session.closed: + await session.close() + + @app.exception_handler(RequestValidationError) async def validation_handler(request, err: RequestValidationError): return JSONResponse( diff --git a/api/http_session.py b/api/http_session.py new file mode 100644 index 0000000..8a928ed --- /dev/null +++ b/api/http_session.py @@ -0,0 +1,6 @@ +from aiohttp import ClientSession +from typing import Optional + +session: Optional[ClientSession] = None + +__all__ = (session,) diff --git a/api/versions/v1/routers/auth/helpers.py b/api/versions/v1/routers/auth/helpers.py index 9231338..9f314fd 100644 --- a/api/versions/v1/routers/auth/helpers.py +++ b/api/versions/v1/routers/auth/helpers.py @@ -1,7 +1,7 @@ import config import typing -import aiohttp +from api.http_session import session from urllib.parse import quote_plus DISCORD_ENDPOINT = "https://discord.com/api" @@ -12,30 +12,28 @@ async def exchange_code( *, code: str, scope: str, redirect_uri: str, grant_type: str = "authorization_code" ) -> typing.Tuple[dict, int]: """Exchange discord oauth code for access and refresh tokens.""" - async with aiohttp.ClientSession() as session: - async with session.post( - "%s/v6/oauth2/token" % DISCORD_ENDPOINT, - data=dict( - code=code, - scope=scope, - grant_type=grant_type, - redirect_uri=redirect_uri, - client_id=config.discord_client_id(), - client_secret=config.discord_client_secret(), - ), - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) as response: - return await response.json(), response.status + async with session.post( + "%s/v6/oauth2/token" % DISCORD_ENDPOINT, + data=dict( + code=code, + scope=scope, + grant_type=grant_type, + redirect_uri=redirect_uri, + client_id=config.discord_client_id(), + client_secret=config.discord_client_secret(), + ), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) as response: + return await response.json(), response.status async def get_user(access_token: str) -> dict: """Coroutine to fetch User data from discord using the users `access_token`""" - async with aiohttp.ClientSession() as session: - async with session.get( - "%s/v6/users/@me" % DISCORD_ENDPOINT, - headers={"Authorization": "Bearer %s" % access_token}, - ) as response: - return await response.json() + async with session.get( + "%s/v6/users/@me" % DISCORD_ENDPOINT, + headers={"Authorization": "Bearer %s" % access_token}, + ) as response: + return await response.json() def format_scopes(scopes: typing.List[str]) -> str: diff --git a/docker-compose.yml b/docker-compose.yml index 12d8969..8409a0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,6 @@ services: volumes: - postgres-data:/var/lib/postgresql/data - - ./postgres/init.sql:/docker-entrypoint-initdb.d/ api: build: @@ -23,8 +22,6 @@ services: restart: unless-stopped depends_on: - postgres - links: - - postgres environment: SECRET_KEY: "${SECRET_KEY}" DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID}" diff --git a/tests/conftest.py b/tests/conftest.py index aba356d..c5f93ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,9 +20,10 @@ def event_loop(): async def app(event_loop: asyncio.AbstractEventLoop) -> AsyncClient: from api import app - client = AsyncClient(app=app, base_url="http://127.0.0.1") - yield client - await client.aclose() + async with AsyncClient(app=app, base_url="http://127.0.0.1:8000") as client: + await app.router.startup() + yield client + await app.router.shutdown() @pytest.fixture(scope="session") diff --git a/tests/test_auth.py b/tests/test_auth.py index d2bc106..3f5183c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,28 +6,32 @@ @pytest.mark.asyncio async def test_redirect_default_code(app: AsyncClient): - res = await app.get("/v1/auth/discord/redirect", allow_redirects=False) + res = await app.get("/api/v1/auth/discord/redirect", allow_redirects=False) assert res.status_code == 307 @pytest.mark.asyncio async def test_redirect_default_url(app: AsyncClient): - res = await app.get("/v1/auth/discord/redirect") - assert str(res.url) == get_redirect( - callback="http://127.0.0.1/v1/auth/discord/callback", + res = await app.get("/api/v1/auth/discord/redirect", allow_redirects=False) + assert res.headers.get("Location") == get_redirect( + callback="http://127.0.0.1:8000/v1/auth/discord/callback", scopes=SCOPES, ) @pytest.mark.asyncio -async def test_redirect_invalid_callback(app: AsyncClient): - res = await app.get("/v1/auth/discord/redirect?callback=okand") - assert res.status_code == 422 +@pytest.mark.parametrize( + "callback,status", + [("okand", 422), ("", 422)], +) +async def test_redirect_invalid_callback(app: AsyncClient, callback, status): + res = await app.get(f"/api/v1/auth/discord/redirect?callback={callback}") + assert res.status_code == status @pytest.mark.asyncio async def test_redirect_valid_callback_url(app: AsyncClient): - res = await app.get("/v1/auth/discord/redirect?callback=https://twt.gg") + res = await app.get("/api/v1/auth/discord/redirect?callback=https://twt.gg") assert str(res.url) == get_redirect( callback="https://twt.gg", scopes=SCOPES, @@ -42,7 +46,7 @@ async def exchange_code(**kwargs): mocker.patch("api.versions.v1.routers.auth.routes.exchange_code", new=exchange_code) res = await app.post( - "/v1/auth/discord/callback", + "/api/v1/auth/discord/callback", json={"code": "okand", "callback": "https://twt.gg"}, ) @@ -56,8 +60,8 @@ async def exchange_code(**kwargs): mocker.patch("api.versions.v1.routers.auth.routes.exchange_code", new=exchange_code) res = await app.post( - "/v1/auth/discord/callback", - json={"code": "invalid", "callback": "https://twt.gg"}, + "/api/v1/auth/discord/callback", + json={"code": "okand", "callback": "https://twt.gg"}, ) assert res.json() == { @@ -89,8 +93,8 @@ async def get_user(**kwargs): mocker.patch("api.versions.v1.routers.auth.routes.exchange_code", new=exchange_code) res = await app.post( - "/v1/auth/discord/callback", - json={"code": "invalid", "callback": "https://twt.gg"}, + "/api/v1/auth/discord/callback", + json={"code": "okand", "callback": "https://twt.gg"}, ) assert res.status_code == 200 From 13d1ba5f608910671ee59f96fed9e9a994aa5699 Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Sun, 1 Aug 2021 18:29:06 +0200 Subject: [PATCH 14/17] fix --- api/__init__.py | 3 +-- api/app.py | 12 ++++++------ launch.py | 12 +++++++++--- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/api/__init__.py b/api/__init__.py index 32dfc0e..0ccbfdd 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,4 +1,3 @@ from .app import app -from .http_session import session -__all__ = ("app", "session") +__all__ = ("app",) diff --git a/api/app.py b/api/app.py index 94b3cb5..5ffccb0 100644 --- a/api/app.py +++ b/api/app.py @@ -28,20 +28,20 @@ @app.on_event("startup") async def on_startup(): """Creates a ClientSession to be used app-wide.""" - from api import session + from api import http_session - if session is None or session.closed: - session = ClientSession() + if http_session.session is None or http_session.session.closed: + http_session.session = ClientSession() log.info("Set http_session.") @app.on_event("shutdown") async def on_shutdown(): """Closes the app-wide ClientSession""" - from api import session + from api import http_session - if session is not None and not session.closed: - await session.close() + if http_session.session is not None and not http_session.closed: + await http_session.session.close() @app.exception_handler(RequestValidationError) diff --git a/launch.py b/launch.py index 49c85c5..b44d4f8 100644 --- a/launch.py +++ b/launch.py @@ -163,10 +163,13 @@ def _dropdb(verbose: bool): @click.option("-h", "--host", default="127.0.0.1") @click.option("-d", "--debug", default=False, is_flag=True) @click.option("-i", "--initdb", default=False, is_flag=True) +@click.option("-r", "--resetdb", default=False, is_flag=True) @click.option("-v", "--verbose", default=False, is_flag=True) -def runserver(host: str, port: str, debug: bool, initdb: bool, verbose: bool): +def runserver( + host: str, port: str, debug: bool, initdb: bool, resetdb: bool, verbose: bool +): """ - Run the Quart app. + Run the FastAPI Server. :param host: Host to run it on. :param port: Port to run it on. @@ -191,7 +194,10 @@ def runserver(host: str, port: str, debug: bool, initdb: bool, verbose: bool): async def worker(): if initdb: - await safe_create_tables(verbose=verbose) + run_async(safe_create_tables(verbose=verbose)) + elif resetdb: + run_async(delete_tables(verbose=verbose)) + run_async(safe_create_tables(verbose=verbose)) await server.serve() From f82ddd8a51766140705517c791846029d14ba05b Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Sun, 1 Aug 2021 18:36:15 +0200 Subject: [PATCH 15/17] whoops --- api/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app.py b/api/app.py index 5ffccb0..df3559e 100644 --- a/api/app.py +++ b/api/app.py @@ -40,7 +40,7 @@ async def on_shutdown(): """Closes the app-wide ClientSession""" from api import http_session - if http_session.session is not None and not http_session.closed: + if http_session.session is not None and not http_session.session.closed: await http_session.session.close() From 5efb4dd1318895fd33abdbeb4ed9959d351a9d7f Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Sun, 1 Aug 2021 19:33:42 +0200 Subject: [PATCH 16/17] mhm --- launch.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/launch.py b/launch.py index b44d4f8..92d27fa 100644 --- a/launch.py +++ b/launch.py @@ -194,10 +194,10 @@ def runserver( async def worker(): if initdb: - run_async(safe_create_tables(verbose=verbose)) + await safe_create_tables(verbose=verbose) elif resetdb: - run_async(delete_tables(verbose=verbose)) - run_async(safe_create_tables(verbose=verbose)) + await delete_tables(verbose=verbose) + await safe_create_tables(verbose=verbose) await server.serve() From a099b0abf5381e6ea3cc66bc39bca67f19e5bb92 Mon Sep 17 00:00:00 2001 From: mohamed040406 Date: Tue, 3 Aug 2021 00:54:21 +0200 Subject: [PATCH 17/17] format imports --- api/app.py | 6 +++--- api/versions/v1/routers/auth/helpers.py | 3 ++- api/versions/v1/routers/auth/routes.py | 4 ++-- config.py | 4 ++-- launch.py | 7 ++++--- tests/test_auth.py | 1 + utils/response.py | 2 +- 7 files changed, 15 insertions(+), 12 deletions(-) diff --git a/api/app.py b/api/app.py index df3559e..2dab4ea 100644 --- a/api/app.py +++ b/api/app.py @@ -1,15 +1,15 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi import FastAPI, HTTPException -from utils.response import JSONResponse from aiohttp import ClientSession + +from utils.response import JSONResponse from api import versions -import logging +import logging log = logging.getLogger() - app = FastAPI() app.router.prefix = "/api" app.router.default_response_class = JSONResponse diff --git a/api/versions/v1/routers/auth/helpers.py b/api/versions/v1/routers/auth/helpers.py index 9f314fd..c880cab 100644 --- a/api/versions/v1/routers/auth/helpers.py +++ b/api/versions/v1/routers/auth/helpers.py @@ -1,9 +1,10 @@ import config import typing -from api.http_session import session from urllib.parse import quote_plus +from api.http_session import session + DISCORD_ENDPOINT = "https://discord.com/api" SCOPES = ["identify"] diff --git a/api/versions/v1/routers/auth/routes.py b/api/versions/v1/routers/auth/routes.py index ccf3bd9..0662c77 100644 --- a/api/versions/v1/routers/auth/routes.py +++ b/api/versions/v1/routers/auth/routes.py @@ -2,12 +2,12 @@ import utils import config -from api.models import User, Token - from pydantic import HttpUrl from fastapi import APIRouter, Request from datetime import datetime, timedelta from fastapi.responses import RedirectResponse + +from api.models import User, Token from .models import CallbackBody, CallbackResponse from .helpers import ( SCOPES, diff --git a/config.py b/config.py index a01ef79..bb16158 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,6 @@ -import os -import typing import logging +import typing +import os log = logging.getLogger("Config") diff --git a/launch.py b/launch.py index 92d27fa..ee47990 100644 --- a/launch.py +++ b/launch.py @@ -1,12 +1,13 @@ -from uvicorn import Config, Server -from typing import Any, Coroutine -from postDB import Model import logging import asyncio import asyncpg import config import click +from uvicorn import Config, Server +from typing import Any, Coroutine +from postDB import Model + logging.basicConfig(level=logging.INFO) diff --git a/tests/test_auth.py b/tests/test_auth.py index 3f5183c..8273ec4 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,4 +1,5 @@ import pytest + from httpx import AsyncClient from pytest_mock import MockerFixture from api.versions.v1.routers.auth.helpers import get_redirect, SCOPES diff --git a/utils/response.py b/utils/response.py index 2b57ea9..09b8243 100644 --- a/utils/response.py +++ b/utils/response.py @@ -1,5 +1,5 @@ -import typing from datetime import datetime +import typing from fastapi.responses import JSONResponse as BaseResponse