diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6529092 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_ine = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 + +[*.yml] +indent_style = space +indent_size = 2 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15eac62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__ +*.swp +*.egg-info +/.vscode +/docs/build +/build +/dist diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..34ca27c --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,13 @@ +Copyright 2018 Sentia MPC B.V. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..5143ed5 --- /dev/null +++ b/Pipfile @@ -0,0 +1,27 @@ +[[source]] + +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + + +[packages] + +requests = "*" +furl = "*" +ansicolors = "*" +"beautifulsoup4" = "*" +pyotp = "*" + + +[dev-packages] + +ipython = "*" +pylint = "*" +yapf = "*" +sphinx = "*" +guzzle-sphinx-theme = "*" +requests-mock = "*" +nose = "*" +twine = "*" +wheel = "*" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..062b378 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,479 @@ +{ + "_meta": { + "hash": { + "sha256": "b24feb170984a38a27a1eb97bd866968d2234458f5014099365ccebe6ea2d924" + }, + "host-environment-markers": { + "implementation_name": "cpython", + "implementation_version": "3.6.4", + "os_name": "posix", + "platform_machine": "x86_64", + "platform_python_implementation": "CPython", + "platform_release": "17.4.0", + "platform_system": "Darwin", + "platform_version": "Darwin Kernel Version 17.4.0: Sun Dec 17 09:19:54 PST 2017; root:xnu-4570.41.2~1/RELEASE_X86_64", + "python_full_version": "3.6.4", + "python_version": "3.6", + "sys_platform": "darwin" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.python.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "ansicolors": { + "hashes": [ + "sha256:00d2dde5a675579325902536738dd27e4fac1fd68f773fe36c21044eb559e187", + "sha256:99f94f5e3348a0bcd43c82e5fc4414013ccc19d70bd939ad71e0133ce9c372e0" + ], + "version": "==1.1.8" + }, + "beautifulsoup4": { + "hashes": [ + "sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11", + "sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76", + "sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89" + ], + "version": "==4.6.0" + }, + "certifi": { + "hashes": [ + "sha256:14131608ad2fd56836d33a71ee60fa1c82bc9d2c8d98b7bdbc631fe1b3cd1296", + "sha256:edbc3f203427eef571f79a7692bb160a2b0f7ccaa31953e99bd17e307cf63f7d" + ], + "version": "==2018.1.18" + }, + "chardet": { + "hashes": [ + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691", + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae" + ], + "version": "==3.0.4" + }, + "furl": { + "hashes": [ + "sha256:6bb7d9ed238a0104db3a638307be7abc35ec989d883f5882e01cb000b9bdbc32" + ], + "version": "==1.0.1" + }, + "idna": { + "hashes": [ + "sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4", + "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f" + ], + "version": "==2.6" + }, + "orderedmultidict": { + "hashes": [ + "sha256:dc2320ca694d90dca4ecc8b9c5fdf71ca61d6c079d6feb085ef8d41585419a36" + ], + "version": "==0.7.11" + }, + "pyotp": { + "hashes": [ + "sha256:8f0df1fcf9e86cec41f0a31c91212b1a04fca6dd353426917222b21864b9310b", + "sha256:dd9130dd91a0340d89a0f06f887dbd76dd07fb95a8886dc4bc401239f2eebd69" + ], + "version": "==2.2.6" + }, + "requests": { + "hashes": [ + "sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b", + "sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e" + ], + "version": "==2.18.4" + }, + "six": { + "hashes": [ + "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb", + "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9" + ], + "version": "==1.11.0" + }, + "urllib3": { + "hashes": [ + "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b", + "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f" + ], + "version": "==1.22" + } + }, + "develop": { + "alabaster": { + "hashes": [ + "sha256:2eef172f44e8d301d25aff8068fddd65f767a3f04b5f15b0f4922f113aa1c732", + "sha256:37cdcb9e9954ed60912ebc1ca12a9d12178c26637abdf124e3cde2341c257fe0" + ], + "version": "==0.7.10" + }, + "appnope": { + "hashes": [ + "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0", + "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71" + ], + "markers": "sys_platform == 'darwin'", + "version": "==0.1.0" + }, + "astroid": { + "hashes": [ + "sha256:b76e5109ff0f386dd229673ca1323d21b1e9bb9c38eaed2cf830882dd7628be2", + "sha256:a92c1197dd496ef2470e73e1c296fc02a719907ee07259744e26a13bda9d4862" + ], + "version": "==1.6.2" + }, + "babel": { + "hashes": [ + "sha256:ad209a68d7162c4cff4b29cdebe3dec4cef75492df501b0049a9433c96ce6f80", + "sha256:8ce4cb6fdd4393edd323227cba3a077bceb2a6ce5201c902c65e730046f41f14" + ], + "version": "==2.5.3" + }, + "certifi": { + "hashes": [ + "sha256:14131608ad2fd56836d33a71ee60fa1c82bc9d2c8d98b7bdbc631fe1b3cd1296", + "sha256:edbc3f203427eef571f79a7692bb160a2b0f7ccaa31953e99bd17e307cf63f7d" + ], + "version": "==2018.1.18" + }, + "chardet": { + "hashes": [ + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691", + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae" + ], + "version": "==3.0.4" + }, + "decorator": { + "hashes": [ + "sha256:94d1d8905f5010d74bbbd86c30471255661a14187c45f8d7f3e5aa8540fdb2e5", + "sha256:7d46dd9f3ea1cf5f06ee0e4e1277ae618cf48dfb10ada7c8427cd46c42702a0e" + ], + "version": "==4.2.1" + }, + "docutils": { + "hashes": [ + "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6", + "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", + "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274" + ], + "version": "==0.14" + }, + "guzzle-sphinx-theme": { + "hashes": [ + "sha256:9b8c1639c343c02c3f3db7df660ddf6f533b5454ee92a5f7b02edaa573fed3e6" + ], + "version": "==0.7.11" + }, + "idna": { + "hashes": [ + "sha256:8c7309c718f94b3a625cb648ace320157ad16ff131ae0af362c9f21b80ef6ec4", + "sha256:2c6a5de3089009e3da7c5dde64a141dbc8551d5b7f6cf4ed7c2568d0cc520a8f" + ], + "version": "==2.6" + }, + "imagesize": { + "hashes": [ + "sha256:3620cc0cadba3f7475f9940d22431fc4d407269f1be59ec9b8edcca26440cf18", + "sha256:5b326e4678b6925158ccc66a9fa3122b6106d7c876ee32d7de6ce59385b96315" + ], + "version": "==1.0.0" + }, + "ipython": { + "hashes": [ + "sha256:fcc6d46f08c3c4de7b15ae1c426e15be1b7932bcda9d83ce1a4304e8c1129df3", + "sha256:51c158a6c8b899898d1c91c6b51a34110196815cc905f9be0fa5878e19355608" + ], + "version": "==6.2.1" + }, + "ipython-genutils": { + "hashes": [ + "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8", + "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8" + ], + "version": "==0.2.0" + }, + "isort": { + "hashes": [ + "sha256:ec9ef8f4a9bc6f71eec99e1806bfa2de401650d996c59330782b89a5555c1497", + "sha256:1153601da39a25b14ddc54955dbbacbb6b2d19135386699e2ad58517953b34af", + "sha256:b9c40e9750f3d77e6e4d441d8b0266cf555e7cdabdcff33c4fd06366ca761ef8" + ], + "version": "==4.3.4" + }, + "jedi": { + "hashes": [ + "sha256:d795f2c2e659f5ea39a839e5230d70a0b045d0daee7ca2403568d8f348d0ad89", + "sha256:d6e799d04d1ade9459ed0f20de47c32f2285438956a677d083d3c98def59fa97" + ], + "version": "==0.11.1" + }, + "jinja2": { + "hashes": [ + "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", + "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" + ], + "version": "==2.10" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:209615b0fe4624d79e50220ce3310ca1a9445fd8e6d3572a896e7f9146bbf019", + "sha256:1b668120716eb7ee21d8a38815e5eb3bb8211117d9a90b0f8e21722c0758cc39", + "sha256:cb924aa3e4a3fb644d0c463cad5bc2572649a6a3f68a7f8e4fbe44aaa6d77e4c", + "sha256:2c1b21b44ac9beb0fc848d3993924147ba45c4ebc24be19825e57aabbe74a99e", + "sha256:320ffd3de9699d3892048baee45ebfbbf9388a7d65d832d7e580243ade426d2b", + "sha256:2df72ab12046a3496a92476020a1a0abf78b2a7db9ff4dc2036b8dd980203ae6", + "sha256:27ea6fd1c02dcc78172a82fc37fcc0992a94e4cecf53cb6d73f11749825bd98b", + "sha256:e5b9e8f6bda48460b7b143c3821b21b452cb3a835e6bbd5dd33aa0c8d3f5137d", + "sha256:7661d401d60d8bf15bb5da39e4dd72f5d764c5aff5a86ef52a042506e3e970ff", + "sha256:61a6cf00dcb1a7f0c773ed4acc509cb636af2d6337a08f362413c76b2b47a8dd", + "sha256:bd6292f565ca46dee4e737ebcc20742e3b5be2b01556dafe169f6c65d088875f", + "sha256:933947e8b4fbe617a51528b09851685138b49d511af0b6c0da2539115d6d4514", + "sha256:d0fc7a286feac9077ec52a927fc9fe8fe2fabab95426722be4c953c9a8bede92", + "sha256:7f3a2d740291f7f2c111d86a1c4851b70fb000a6c8883a59660d95ad57b9df35", + "sha256:5276db7ff62bb7b52f77f1f51ed58850e315154249aceb42e7f4c611f0f847ff", + "sha256:94223d7f060301b3a8c09c9b3bc3294b56b2188e7d8179c762a1cda72c979252", + "sha256:6ae6c4cb59f199d8827c5a07546b2ab7e85d262acaccaacd49b62f53f7c456f7", + "sha256:f460d1ceb0e4a5dcb2a652db0904224f367c9b3c1470d5a7683c0480e582468b", + "sha256:e81ebf6c5ee9684be8f2c87563880f93eedd56dd2b6146d8a725b50b7e5adb0f", + "sha256:81304b7d8e9c824d058087dcb89144842c8e0dea6d281c031f59f0acf66963d4", + "sha256:ddc34786490a6e4ec0a855d401034cbd1242ef186c20d79d2166d6a4bd449577", + "sha256:7bd527f36a605c914efca5d3d014170b2cb184723e423d26b1fb2fd9108e264d", + "sha256:ab3ca49afcb47058393b0122428358d2fbe0408cf99f1b58b295cfeb4ed39109", + "sha256:7cb54db3535c8686ea12e9535eb087d32421184eacc6939ef15ef50f83a5e7e2", + "sha256:0ce34342b419bd8f018e6666bfef729aec3edf62345a53b537a4dcc115746a33", + "sha256:e34b155e36fa9da7e1b7c738ed7767fc9491a62ec6af70fe9da4a057759edc2d", + "sha256:50e3b9a464d5d08cc5227413db0d1c4707b6172e4d4d915c1c70e4de0bbff1f5", + "sha256:27bf62cb2b1a2068d443ff7097ee33393f8483b570b475db8ebf7e1cba64f088", + "sha256:eb91be369f945f10d3a49f5f9be8b3d0b93a4c2be8f8a5b83b0571b8123e0a7a" + ], + "version": "==1.3.1" + }, + "markupsafe": { + "hashes": [ + "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" + ], + "version": "==1.0" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "nose": { + "hashes": [ + "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", + "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", + "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" + ], + "version": "==1.3.7" + }, + "packaging": { + "hashes": [ + "sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0", + "sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b" + ], + "version": "==17.1" + }, + "parso": { + "hashes": [ + "sha256:a7bb86fe0844304869d1c08e8bd0e52be931228483025c422917411ab82d628a", + "sha256:5815f3fe254e5665f3c5d6f54f086c2502035cb631a91341591b5a564203cffb" + ], + "version": "==0.1.1" + }, + "pexpect": { + "hashes": [ + "sha256:6ff881b07aff0cb8ec02055670443f784434395f90c3285d2ae470f921ade52a", + "sha256:67b85a1565968e3d5b5e7c9283caddc90c3947a2625bed1905be27bd5a03e47d" + ], + "markers": "sys_platform != 'win32'", + "version": "==4.4.0" + }, + "pickleshare": { + "hashes": [ + "sha256:c9a2541f25aeabc070f12f452e1f2a8eae2abd51e1cd19e8430402bdf4c1d8b5", + "sha256:84a9257227dfdd6fe1b4be1319096c20eb85ff1e82c7932f36efccfe1b09737b" + ], + "version": "==0.7.4" + }, + "pkginfo": { + "hashes": [ + "sha256:a39076cb3eb34c333a0dd390b568e9e1e881c7bf2cc0aee12120636816f55aee", + "sha256:5878d542a4b3f237e359926384f1dde4e099c9f5525d236b1840cf704fa8d474" + ], + "version": "==1.4.2" + }, + "prompt-toolkit": { + "hashes": [ + "sha256:3f473ae040ddaa52b52f97f6b4a493cfa9f5920c255a12dc56a7d34397a398a4", + "sha256:1df952620eccb399c53ebb359cc7d9a8d3a9538cb34c5a1344bdbeb29fbcc381", + "sha256:858588f1983ca497f1cf4ffde01d978a3ea02b01c8a26a8bbc5cd2e66d816917" + ], + "version": "==1.0.15" + }, + "ptyprocess": { + "hashes": [ + "sha256:e8c43b5eee76b2083a9badde89fd1bbce6c8942d1045146e100b7b5e014f4f1a", + "sha256:e64193f0047ad603b71f202332ab5527c5e52aa7c8b609704fc28c0dc20c4365" + ], + "version": "==0.5.2" + }, + "pygments": { + "hashes": [ + "sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d", + "sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc" + ], + "version": "==2.2.0" + }, + "pylint": { + "hashes": [ + "sha256:34ab1a62fbdd48059d082f5a52b7e719a39b757a53ecbf0b2b7169b9c6a2cc28", + "sha256:c77311859e0c2d7932095f30d2b1bfdc4b6fe111f534450ba727a52eae330ef2" + ], + "version": "==1.8.3" + }, + "pyparsing": { + "hashes": [ + "sha256:fee43f17a9c4087e7ed1605bd6df994c6173c1e977d7ade7b651292fab2bd010", + "sha256:0832bcf47acd283788593e7a0f542407bd9550a55a8a8435214a1960e04bcb04", + "sha256:9e8143a3e15c13713506886badd96ca4b579a87fbdf49e550dbfc057d6cb218e", + "sha256:281683241b25fe9b80ec9d66017485f6deff1af5cde372469134b56ca8447a07", + "sha256:b8b3117ed9bdf45e14dcc89345ce638ec7e0e29b2b579fa1ecf32ce45ebac8a5", + "sha256:8f1e18d3fd36c6795bb7e02a39fd05c611ffc2596c1e0d995d34d67630426c18", + "sha256:e4d45427c6e20a59bf4f88c639dcc03ce30d193112047f94012102f235853a58" + ], + "version": "==2.2.0" + }, + "pytz": { + "hashes": [ + "sha256:ed6509d9af298b7995d69a440e2822288f2eca1681b8cce37673dbb10091e5fe", + "sha256:f93ddcdd6342f94cea379c73cddb5724e0d6d0a1c91c9bdef364dc0368ba4fda", + "sha256:61242a9abc626379574a166dc0e96a66cd7c3b27fc10868003fa210be4bff1c9", + "sha256:ba18e6a243b3625513d85239b3e49055a2f0318466e0b8a92b8fb8ca7ccdf55f", + "sha256:07edfc3d4d2705a20a6e99d97f0c4b61c800b8232dc1c04d87e8554f130148dd", + "sha256:3a47ff71597f821cd84a162e71593004286e5be07a340fd462f0d33a760782b5", + "sha256:5bd55c744e6feaa4d599a6cbd8228b4f8f9ba96de2c38d56f08e534b3c9edf0d", + "sha256:887ab5e5b32e4d0c86efddd3d055c1f363cbaa583beb8da5e22d2fa2f64d51ef", + "sha256:410bcd1d6409026fbaa65d9ed33bf6dd8b1e94a499e32168acfc7b332e4095c0" + ], + "version": "==2018.3" + }, + "requests": { + "hashes": [ + "sha256:6a1b267aa90cac58ac3a765d067950e7dbbf75b1da07e895d1f594193a40a38b", + "sha256:9c443e7324ba5b85070c4a818ade28bfabedf16ea10206da1132edaa6dda237e" + ], + "version": "==2.18.4" + }, + "requests-mock": { + "hashes": [ + "sha256:96a1e45b1c0bd18d14fcb2d55b3b09d6d46237e37bcae3155df4cb75bc42619e", + "sha256:2931887853c42e1d73879983d5bf03041109472991c5b4b8dba5d11ed23b9d0b" + ], + "version": "==1.4.0" + }, + "requests-toolbelt": { + "hashes": [ + "sha256:42c9c170abc2cacb78b8ab23ac957945c7716249206f90874651971a4acff237", + "sha256:f6a531936c6fa4c6cfce1b9c10d5c4f498d16528d2a54a22ca00011205a187b5" + ], + "version": "==0.8.0" + }, + "simplegeneric": { + "hashes": [ + "sha256:dc972e06094b9af5b855b3df4a646395e43d1c9d0d39ed345b7393560d0b9173" + ], + "version": "==0.8.1" + }, + "six": { + "hashes": [ + "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb", + "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9" + ], + "version": "==1.11.0" + }, + "snowballstemmer": { + "hashes": [ + "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89", + "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128" + ], + "version": "==1.2.1" + }, + "sphinx": { + "hashes": [ + "sha256:41ae26acc6130ccf6ed47e5cca73742b80d55a134f0ab897c479bba8d3640b8e", + "sha256:da987de5fcca21a4acc7f67a86a363039e67ac3e8827161e61b91deb131c0ee8" + ], + "version": "==1.7.1" + }, + "sphinxcontrib-websupport": { + "hashes": [ + "sha256:f4932e95869599b89bf4f80fc3989132d83c9faa5bf633e7b5e0c25dffb75da2", + "sha256:7a85961326aa3a400cd4ad3c816d70ed6f7c740acd7ce5d78cd0a67825072eb9" + ], + "version": "==1.0.1" + }, + "tqdm": { + "hashes": [ + "sha256:05e991ecb0f874046ddcb374396a626afd046fb4d31f73633ea752b844458a7a", + "sha256:2aea9f81fdf127048667e0ba22f5fc10ebc879fb838dc52dcf055242037ec1f7" + ], + "version": "==4.19.8" + }, + "traitlets": { + "hashes": [ + "sha256:c6cb5e6f57c5a9bdaa40fa71ce7b4af30298fbab9ece9815b5d995ab6217c7d9", + "sha256:9c4bd2d267b7153df9152698efb1050a5d84982d3384a37b2c1f7723ba3e7835" + ], + "version": "==4.3.2" + }, + "twine": { + "hashes": [ + "sha256:eff86e20fdffef8abb0b638784c62d0348dac4c80380907e39b732c56e9192fb", + "sha256:c3540f2b98667698412b0dc9f5e40c8c1a08a9e79e255c9c21339105eb4ca57a" + ], + "version": "==1.10.0" + }, + "urllib3": { + "hashes": [ + "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b", + "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f" + ], + "version": "==1.22" + }, + "wcwidth": { + "hashes": [ + "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c", + "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e" + ], + "version": "==0.1.7" + }, + "wheel": { + "hashes": [ + "sha256:e721e53864f084f956f40f96124a74da0631ac13fbbd1ba99e8e2b5e9cafdf64", + "sha256:9515fe0a94e823fd90b08d22de45d7bde57c90edce705b22f5e1ecf7e1b653c8" + ], + "version": "==0.30.0" + }, + "wrapt": { + "hashes": [ + "sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6" + ], + "version": "==1.10.11" + }, + "yapf": { + "hashes": [ + "sha256:dd23b52edbb4c0461d0383050f7886175b0df9ab8fd0b67edd41f94e25770993", + "sha256:7d8ae3567f3fb2d288f127d35e4decb3348c96cd091001e02e818465da618f90" + ], + "version": "==0.21.0" + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..293b8e1 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# coto: An AWS Management Console Client + +[![Documentation Status](https://readthedocs.org/projects/coto/badge/?version=latest)](http://coto.readthedocs.io/en/latest/?badge=latest) +[![Version](http://img.shields.io/pypi/v/coto.svg?style=flat)](https://pypi.python.org/pypi/coto/) + +Almost any AWS service can be fully controlled using the AWS API, for this we strongly recommend the use of [boto3](http://boto3.readthedocs.io/). The problem is, that there exist some administrative tasks for which there is no public API, and there exist some [AWS tasks that still require the AWS Account Root User](https://docs.aws.amazon.com/general/latest/gr/aws_tasks-that-require-root.html). + +For example when creating a new account in an AWS Organization, there are some things that you are unable to do using the documented APIs, such as: + + * set tax registration information (no documented API) + * set additional contacts (no documented API) + * reset AWS Account Root User password (no documented API) + * setup MFA for the AWS Account Root User (requires root user) + +> **Note:** +> +> This project provides a client for the undocumented APIs that are used by the AWS Management Console. **These APIs will be changing without any upfront warning!** As a result of this, coto can break at any moment. + + +## Examples + + +### Login using a boto session. + +```python +import boto +import coto + +session = coto.Session( + boto_session = boto.Session() +) +``` + + +### Login using root user password. + +```python +import coto + +session = coto.Session( + email = 'email@example.com', + password = 's3cur3 p4ssw0rd!' +) +``` + + +### Login using root user password with virtual MFA. + +```python +import coto + +session = coto.Session( + email = 'email@example.com', + password = 's3cur3 p4ssw0rd!', + mfa_secret = 'MFAxSECRETxSEEDxXXXXXXXXXXXXXXXXXX' +) +``` + + +### Get account information + +```python +iam = session.client('iam') +iam.get_account_info() +``` + + +### Set tax registration + +```python +billing = session.client('billing') +billing.set_tax_registration( + { + 'address': { + 'addressLine1': 'Adresweg 1', + 'addressLine2': None, + 'city': 'Delft', + 'countryCode': 'NL', + 'postalCode': '2600 AA', + 'state': 'Zuid-Holland', + }, + 'authority': {'country': 'NL', 'state': None}, + 'legalName': 'Besloten Venootschap B.V.', + 'localTaxRegistration': False, + 'registrationId': 'NL000000000B01', + } +) +``` + +## Development + +``` +pipenv install -d +pipenv run nosetests tests +cd docs +pipenv run make html +``` diff --git a/coto/__init__.py b/coto/__init__.py new file mode 100644 index 0000000..6fdd56e --- /dev/null +++ b/coto/__init__.py @@ -0,0 +1 @@ +from coto.session import Session diff --git a/coto/clients/__init__.py b/coto/clients/__init__.py new file mode 100644 index 0000000..1a003a1 --- /dev/null +++ b/coto/clients/__init__.py @@ -0,0 +1,2 @@ +from coto.clients.billing import Billing +from coto.clients.iam import Iam diff --git a/coto/clients/billing.py b/coto/clients/billing.py new file mode 100644 index 0000000..3ec0ad8 --- /dev/null +++ b/coto/clients/billing.py @@ -0,0 +1,274 @@ +import json + + +class Billing: + """ + A low-level client representing Biling: + + .. code-block:: python + + import coto + + session = coto.Session() + client = session.client('billing') + + These are the available methods: + + * :py:meth:`delete_tax_registration` + * :py:meth:`list_alternate_contacts` + * :py:meth:`list_tax_registrations` + * :py:meth:`set_alternate_contacts` + * :py:meth:`set_tax_registration` + """ + + def __init__(self, session): + self.session = session + self.xsrf_token = self._xsrf_token() + + + def _xsrf_token(self): + r = self.session._get( + 'https://console.aws.amazon.com/billing/home?region=eu-central-1&state=hashArgs%23' + ) + + if r.status_code != 200: + raise Exception("failed get billing xsrf token") + + return r.headers['x-awsbc-xsrf-token'] + + + def _get(self, api): + r = self.session._get( + "https://console.aws.amazon.com/billing/rest/v1.0/{0}?state=hashArgs%23". + format(api), + headers={'x-awsbc-xsrf-token': self.xsrf_token}) + + if r.status_code != 200: + raise Exception("failed get {0}".format(api)) + + return r + + + def _put(self, api, data): + r = self.session._put( + "https://console.aws.amazon.com/billing/rest/v1.0/{0}?state=hashArgs%23". + format(api), + headers={ + 'x-awsbc-xsrf-token': self.xsrf_token, + 'Content-Type': 'application/json', + }, + data=json.dumps(data)) + + if r.status_code != 200: + raise Exception("failed put {0}".format(api)) + + return r + + + # billing api + + def list_alternate_contacts(self): + """ + Lists the alternate contacts set for the account. In order to keep the + right people in the loop, you can add an alternate contact for Billing, + Operations, and Security communications. + + Request Syntax: + .. code-block:: python + + response = client.list_alternate_contacts() + + Returns: + dict: Response Syntax + + .. code-block:: python + + [ + { + 'contactId': int, + 'contactType': 'billing' | 'operations' | 'security', + 'email': str, + 'name': str, + 'phoneNumber': str, + 'title': str + }, + ] + """ + r = self._get('additionalcontacts') + return json.loads(r.text) + + + def set_alternate_contacts(self, AlternateContacts): + """ + Sets the alternate contacts set for the account. In order to keep the + right people in the loop, you can add an alternate contact for Billing, + Operations, and Security communications. + + Please note that, the primary account holder will continue to receive + all email communications. + + Contact Types: + ``billing``: + The alternate Billing contact will receive billing-related + notifications, such as invoice availability notifications. + ``operations``: + The alternate Operations contact will receive + operations-related notifications. + ``security``: + The alternate Security contact will receive + security-related notifications. For additional AWS + security-related notifications, please access the Security + Bulletins RSS Feed. + + Request Syntax: + .. code-block:: python + + response = client.set_alternate_contacts( + AlternateContacts = [ + { + 'contactType': 'billing', + 'email': str, + 'name': str, + 'phoneNumber': str, + 'title': str + }, + { + 'contactType': 'operations', + 'email': str, + 'name': str, + 'phoneNumber': str, + 'title': str + }, + { + 'contactType': 'security', + 'email': str, + 'name': str, + 'phoneNumber': str, + 'title': str + }, + ] + ) + Args: + AlternateContacts (list): List of alternate contacts. + """ + self._put('additionalcontacts', AlternateContacts) + + + def list_tax_registrations(self): + """ + Lists the tax registrations set for the account. + Set your tax information so that your 1099K or W-88EN is generated + appropriately. Setting this information up also allows you to sell more + than 200 transactions or $20,000 in Reserved Instances. + + Status: + ``Verified``: + Verified + ``Pending``: + Pending + ``Deleted``: + Deleted + + Request Syntax: + .. code-block:: python + + response = client.list_tax_registrations() + + Returns: + dict: Response Syntax + + .. code-block:: python + + [ + { + 'address': { + 'addressLine1': str, + 'addressLine2': str, + 'city': str, + 'countryCode': str, + 'postalCode': str, + 'state': str, + }, + 'authority': { + 'country': str, + 'state': str + }, + 'currentStatus': 'Verified' | 'Pending', + 'legalName': str, + 'localTaxRegistration': bool, + 'registrationId': str + }, + ] + """ + r = self._get('taxexemption/eu/vat/information') + return json.loads(r.text)['taxRegistrationList'] + + + def set_tax_registration(self, TaxRegistration): + """ + Set the tax registrations for the account. + Set your tax information so that your 1099K or W-88EN is generated + appropriately. Setting this information up also allows you to sell more + than 200 transactions or $20,000 in Reserved Instances. + + Request Syntax: + .. code-block:: python + + response = client.set_tax_registration( + TaxRegistration = { + 'address': { + 'addressLine1': str, + 'addressLine2': str, + 'city': str, + 'countryCode': str, + 'postalCode': str, + 'state': str, + }, + 'authority': { + 'country': str, + 'state': str + }, + 'legalName': str, + 'localTaxRegistration': bool, + 'registrationId': str, + } + ) + + Args: + TaxRegistration (dict): Desired tax registration. + """ + self._put('taxexemption/eu/vat/information', TaxRegistration) + + + def delete_tax_registration(self, TaxRegistration): + """ + Delete the given tax registrations from the account. + + Request Syntax: + .. code-block:: python + + response = client.delete_tax_registration( + TaxRegistration = { + 'address': { + 'addressLine1': str, + 'addressLine2': str, + 'city': str, + 'countryCode': str, + 'postalCode': str, + 'state': str, + }, + 'authority': { + 'country': str, + 'state': str + }, + 'legalName': str, + 'localTaxRegistration': bool, + 'registrationId': str, + } + ) + + Args: + TaxRegistration (dict): Tax registration to delete. + """ + TaxRegistration['currentStatus'] = 'Deleted' + return self.set_tax_registration(TaxRegistration) diff --git a/coto/clients/iam.py b/coto/clients/iam.py new file mode 100644 index 0000000..fc24b7c --- /dev/null +++ b/coto/clients/iam.py @@ -0,0 +1,274 @@ +from bs4 import BeautifulSoup +from pyotp import TOTP +from datetime import datetime, timedelta +import json + + +class Iam: + """ + A low-level client representing IAM: + + .. code-block:: python + + import coto + + session = coto.Session() + client = session.client('iam') + + These are the available methods: + + * :py:meth:`create_virtual_mfa_device` + * :py:meth:`deactivate_mfa_device` + * :py:meth:`enable_mfa_device` + * :py:meth:`get_account_info` + """ + + def __init__(self, console): + self.console = console + self.xsrf_token = self._xsrf_token() + + + def _url(self, api): + return "https://console.aws.amazon.com/iam/service/{0}".format(api) + + + def _xsrf_token(self): + r = self.console._get( + 'https://console.aws.amazon.com/iam/home?&state=hashArgs%23' + ) + + if r.status_code != 200: + raise Exception("failed get token") + + r = self.console._get('https://console.aws.amazon.com/iam/home?') + + if r.status_code != 200: + raise Exception("failed get token") + + soup = BeautifulSoup(r.text, 'html.parser') + for m in soup.find_all('meta'): + if 'id' in m.attrs and m['id'] == "xsrf-token": + return m['data-token'] + + raise Exception('unable to obtain IAM xsrf_token') + + + def _get(self, api): + r = self.console._get( + self._url(api), + headers={'X-CSRF-Token': self.xsrf_token}) + + if 'X-CSRF-Token' in r.headers: + self.xsrf_token = r.headers['X-CSRF-Token'] + + if r.status_code != 200: + print(r.text) + raise Exception("failed get {0}".format(api)) + + return json.loads(r.text) + + + def _post(self, api, data): + r = self.console._post( + self._url(api), + headers={ + 'X-CSRF-Token': self.xsrf_token, + 'Content-Type': 'application/json', + }, + data=json.dumps(data)) + + if 'X-CSRF-Token' in r.headers: + self.xsrf_token = r.headers['X-CSRF-Token'] + + if r.status_code != 200: + print(r.text) + raise Exception("failed post {0}".format(api)) + + return json.loads(r.text) + + + # iam api + + def get_account_info(self): + """ + Retrieves a summary of account information. + + Request Syntax: + .. code-block:: python + + response = client.get_account_info() + + Returns: + dict: Response Syntax + + .. code-block:: python + + { + 'aliases': [], + 'checklistSummary': { + 'checklistItems': [ + { + 'complete': bool, + 'fetchSucceeded': bool, + 'identifier': str, + }, + ], + 'error': bool, + 'errorCount': int, + 'totalCompletedCount': int, + 'totalCount': int + }, + 'errorMap': {}, + 'errors': [], + 'invalidPolicyExist': bool, + 'summaryMap': { + 'AccessKeysPerUserQuota': int, + 'AccountAccessKeysPresent': int, + 'AccountMFAEnabled': int, + 'AccountSigningCertificatesPresent': int, + 'AssumeRolePolicySizeQuota': int, + 'AttachedPoliciesPerGroupQuota': int, + 'AttachedPoliciesPerRoleQuota': int, + 'AttachedPoliciesPerUserQuota': int, + 'GroupPolicySizeQuota': int, + 'Groups': int, + 'GroupsPerUserQuota': int, + 'GroupsQuota': int, + 'InstanceProfiles': int, + 'InstanceProfilesQuota': int, + 'MFADevices': int, + 'MFADevicesInUse': int, + 'Policies': int, + 'PoliciesQuota': int, + 'PolicySizeQuota': int, + 'PolicyVersionsInUse': int, + 'PolicyVersionsInUseQuota': int, + 'Providers': int, + 'RolePolicySizeQuota': int, + 'Roles': int, + 'RolesQuota': int, + 'ServerCertificates': int, + 'ServerCertificatesQuota': int, + 'SigningCertificatesPerUserQuota': int, + 'UserPolicySizeQuota': int, + 'Users': int, + 'UsersQuota': int, + 'VersionsPerPolicyQuota': int, + } + } + """ + return self._get('account') + + + def create_virtual_mfa_device(self, VirtualMFADeviceName = 'root-account-mfa-device', Path = '/'): + """ + Creates a new virtual MFA device for the AWS account. After creating the + virtual MFA, use :py:meth:`enable_mfa_device` to attach the MFA device to the + account root user. + + Request Syntax: + .. code-block:: python + + response = client.create_virtual_mfa_device( + VirtualMFADeviceName=str, + Path=str + ) + + Args: + VirtualMFADeviceName (str): The name of the virtual MFA device. Use with path to uniquely identify a virtual MFA device. + This parameter is optional. If it is not included, it defaults to ``root-account-mfa-device``. + Path (str): The path for the virtual MFA device. For more information about paths, see IAM Identifiers in the IAM User Guide. + This parameter is optional. If it is not included, it defaults to a slash (/). + + Returns: + dict: Response Syntax + + .. code-block:: python + + { + "serialNumber": str, + "qrCodePNG": str, + "base32StringSeed": str + } + + **serialNumber** (*str*) -- The serial number associated with VirtualMFADevice. + + **qrCodePNG** (*str*) -- A QR code PNG image that encodes + ``otpauth://totp/$virtualMFADeviceName@$AccountName?secret=$Base32String`` + where ``$virtualMFADeviceName`` is one of the create call arguments, + ``$AccountName`` is the user name if set (otherwise, the account + ID), and ``$Base32String`` is the seed in Base32 format. The + ``$Base32String`` value is Base64-encoded. + + **base32StringSeed** (*str*) -- The Base32 seed defined as specified + in RFC3548 . The Base32StringSeed is Base64-encoded. + """ + r = self._post('mfa/createVirtualMfa', { + 'path': Path, + 'deviceName': VirtualMFADeviceName + }) + return r + + + def enable_mfa_device(self, SerialNumber, Base32StringSeed): + """ + Enables the specified MFA device and associates it with the account root + user. When enabled, the MFA device is required for every subsequent + login by the account root user. + + Request Syntax: + .. code-block:: python + + response = client.enable_mfa_device( + SerialNumber='string', + Base32StringSeed=string + ) + + Args: + SerialNumber (str): The serial number that uniquely identifies the + MFA device. For virtual MFA devices, the serial number is the + device ARN. + Base32StringSeed (str): The Base32 seed defined as specified in + RFC3548. The Base32StringSeed is Base64-encoded. + + Returns: + dict: Response Syntax + + .. code-block:: python + + { + 'success': bool + } + """ + totp = TOTP(Base32StringSeed) + r = self._post('root/mfa/associate', { + 'serial': SerialNumber, + 'codes': [ + totp.at(datetime.now() - timedelta(seconds=30)), + totp.at(datetime.now()) + ] + }) + return r + + + def deactivate_mfa_device(self, SerialNumber): + """ + Deactivates the specified MFA device and removes it from association + with the account root user. + + Request Syntax: + .. code-block:: python + + response = client.deactivate_mfa_device( + SerialNumber='string' + ) + + Args: + SerialNumber (str): The serial number that uniquely identifies the + MFA device. For virtual MFA devices, the serial number is the + device ARN. + """ + r = self._post('root/mfa/disassociate', { + 'serial': SerialNumber + }) + return r diff --git a/coto/session/__init__.py b/coto/session/__init__.py new file mode 100644 index 0000000..b4fa2be --- /dev/null +++ b/coto/session/__init__.py @@ -0,0 +1 @@ +from coto.session.session import Session diff --git a/coto/session/session.py b/coto/session/session.py new file mode 100644 index 0000000..9048b57 --- /dev/null +++ b/coto/session/session.py @@ -0,0 +1,171 @@ +import requests +import json +from urllib.parse import unquote +from colors import color +from coto.session.signin import Federation, Root +from coto.clients import Billing, Iam + + +def dr(r): + for i in r.history + [r]: + if i.status_code < 400: + fg = 'green' + else: + fg = 'red' + + print() + + print(color( + str(i.status_code) + " " + i.request.url, + fg=fg, + style='underline' + )) + + for k, v in i.request.headers.items(): + if k == 'Cookie': + print( + color(k+':', fg='blue') + ) + for c in v.split(";"): + c = c.strip() + (n, c) = c.split('=', maxsplit=1) + print( + color(' ' + n + ': ', fg='blue') + + unquote(c) + ) + else: + print( + color(k+':', fg='blue'), + v + ) + + for k, v in i.headers.items(): + print( + color(k+':', fg='yellow'), + v + ) + + +class Session: + """ + The Session class represents a session with the AWS Management Console. + + Use the `client` method to obtain a client for one of the supported + services. + """ + + def __init__(self, **kwargs): + """ + Args: + You can pass arguments for the signin method here. + + debug: bool, enable debugging + """ + self.debug = kwargs.get('debug', False) + self.root = False + self.session = requests.Session() + self.authenticated = False + + self.timeout = (3.1, 10) + self.user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.186 Safari/537.36' + + if len(kwargs) > 0: + self.signin(**kwargs) + + + def signin(self, **kwargs): + """ + Signin to the AWS Management Console. + + There are various ways to sign in: + + * Using a boto.Session object, pass the `boto_session` argument. + * Using the Account Root User, pass the `email`, `password`, and + optionally `mfa_secret` arguments. + + Args: + + boto_session: A boto.Session object, the credentials of this session + are retrieved and used to signin to the console + email: Email address to + """ + if 'boto_session' in kwargs: + boto_session = kwargs.get('boto_session') + return Federation(self).signing(boto_session) + + elif 'email' in kwargs and 'password' in kwargs: + email = kwargs.get('email') + password = kwargs.get('password') + mfa_secret = kwargs.get('mfa_secret') + return Root(self).signin(email, password, mfa_secret) + + + # http requests + def _set_defaults(self, kwargs): + if not 'timeout' in kwargs: + kwargs['timeout'] = self.timeout + + if not 'headers' in kwargs: + kwargs['headers'] = {} + + kwargs['headers']['Accept'] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" + kwargs['headers']['User-Agent'] = self.user_agent + + + def _get(self, url, **kwargs): + self._set_defaults(kwargs) + r = self.session.get(url, **kwargs) + if self.debug: + dr(r) + return r + + + def _post(self, url, **kwargs): + self._set_defaults(kwargs) + r = self.session.post(url, **kwargs) + if self.debug: + dr(r) + return r + + + def _put(self, url, **kwargs): + self._set_defaults(kwargs) + r = self.session.put(url, **kwargs) + if self.debug: + dr(r) + return r + + + def _delete(self, url, **kwargs): + self._set_defaults(kwargs) + r = self.session.delete(url, **kwargs) + if self.debug: + dr(r) + return r + + + # mimic boto3 :) + def client(self, service): + """ + Create a client for a service. + + Supported services: + + * `billing` + * `iam` + + Args: + service: name of the service, eg., `billing` + + Returns: + object: service client + """ + if not self.authenticated: + raise Exception('please signin before calling client') + + if service == 'billing': + return Billing(self) + elif service == 'iam': + return Iam(self) + else: + raise Exception("service {0} unsupported".format(service)) diff --git a/coto/session/signin/__init__.py b/coto/session/signin/__init__.py new file mode 100644 index 0000000..edf5bef --- /dev/null +++ b/coto/session/signin/__init__.py @@ -0,0 +1,2 @@ +from coto.session.signin.root import Root +from coto.session.signin.federation import Federation diff --git a/coto/session/signin/federation.py b/coto/session/signin/federation.py new file mode 100644 index 0000000..e46ce13 --- /dev/null +++ b/coto/session/signin/federation.py @@ -0,0 +1,46 @@ +from furl import furl +import json +import requests + + +def get_signin_url(session): + url = furl('https://signin.aws.amazon.com/federation') + + url.args['Action'] = "login" + url.args['Issuer'] = None + url.args['Destination'] = "https://console.aws.amazon.com/" + url.args['SigninToken'] = get_signin_token(session) + + return url.url + + +def get_signin_token(session): + credentials = session.get_credentials() + + url = "https://signin.aws.amazon.com/federation" + response = requests.get(url, + params={ + "Action": "getSigninToken", + "Session": json.dumps({ + "sessionId": credentials.access_key, + "sessionKey": credentials.secret_key, + "sessionToken": credentials.token, + }) + }, + timeout=(3.1, 5) + ) + return json.loads(response.text)["SigninToken"] + + +class Federation: + def __init__(self, session): + self.session = session + + + def signing(self, boto_session): + r = self.session._get(get_signin_url(boto_session)) + if r.status_code != 200: + raise Exception("failed session signin") + + self.session.authenticated = True + return True diff --git a/coto/session/signin/root.py b/coto/session/signin/root.py new file mode 100644 index 0000000..52078f8 --- /dev/null +++ b/coto/session/signin/root.py @@ -0,0 +1,145 @@ +from bs4 import BeautifulSoup +from pyotp import TOTP +import json + + +class Root: + REDIRECT_URL = "https://console.aws.amazon.com/console/home?state=hashArgs%23&isauthcode=true" + + + def __init__(self, session): + self.session = session + self._csrf_token = None + self._session_id = None + + + def csrf_token(self): + if self._csrf_token == None: + self.get_tokens() + + return self._csrf_token + + + def session_id(self): + if self._session_id == None: + self.get_tokens() + + return self._session_id + + + def get_tokens(self): + r = self.session._get( + "https://signin.aws.amazon.com/signin?redirect_uri=https%3A%2F%2Fconsole.aws.amazon.com%2Fconsole%2Fhome%3Fstate%3DhashArgs%2523%26isauthcode%3Dtrue&client_id=arn%3Aaws%3Aiam%3A%3A015428540659%3Auser%2Fhomepage&forceMobileApp=0" + ) + + if r.status_code != 200: + raise Exception("failed get tokens") + + soup = BeautifulSoup(r.text, 'html.parser') + meta = { + m['name']: m['content'] + for m in soup.find_all('meta') + if 'name' in m.attrs + } + self._csrf_token = meta['csrf_token'] + self._session_id = meta['session_id'] + + + def get_account_type(self, email): + r = self.session._post( + "https://signin.aws.amazon.com/signin", + data={ + 'action': 'resolveAccountType', + 'redirect_uri': self.REDIRECT_URL, + 'email': email, + 'csrf': self.csrf_token(), + 'sessionId': self.session_id(), + } + ) + + if r.status_code != 200: + raise Exception("failed get mfa status for {0}".format(email)) + + result = json.loads(r.text) + + if not result['state'] == 'SUCCESS': + raise Exception("unable to find account type for {0}".format(email)) + + return result['properties']['resolvedAccountType'] + + + def get_mfa_status(self, email): + r = self.session._post( + "https://signin.aws.amazon.com/mfa", + data={ + 'email': email, + 'redirect_url': self.REDIRECT_URL, + 'csrf': self.csrf_token(), + 'sessionId': self.session_id(), + } + ) + + if r.status_code != 200: + raise Exception("failed get mfa status for {0}".format(email)) + + return json.loads(r.text) + + + def mfa_required(self, email): + mfa = self.get_mfa_status(email) + if 'mfaType' in mfa: + if mfa['mfaType'] != 'SW': + raise Exception("cannot handle hardware mfa tokens") + + return True + + return False + + + def signin(self, email, password, mfa_secret=None): + # check account type + account_type = self.get_account_type(email) + + # check mfa + mfa_required = self.mfa_required(email) + if mfa_required and (mfa_secret is None or len(mfa_secret) == 0): + raise Exception("account mfa protected but no secret provided") + + if not mfa_required: + mfa_secret = None + + if account_type == 'Decoupled': + return self.signin_decoupled(email, password, mfa_secret) + elif account_type == 'Coupled': + raise Exception("coupled account signin not supported {0}".format(email)) + elif account_type == 'Unknown': + raise Exception("account {0} not active".format(email)) + else: + raise Exception("unsupported account type {0}".format(email)) + + + def signin_decoupled(self, email, password, mfa_secret=None): + data = { + 'action': 'authenticateRoot', + 'email': email, + 'password': password, + 'redirect_uri': self.REDIRECT_URL, + 'client_id': 'arn:aws:iam::015428540659:user/homepage', + 'csrf': self.csrf_token(), + 'sessionId': self.session_id() + } + + if mfa_secret is not None: + data['mfa1'] = TOTP(mfa_secret).now() + + r = self.session._post("https://signin.aws.amazon.com/signin", data=data) + if r.status_code != 200: + raise Exception("failed to login {0}".format(email)) + + result = json.loads(r.text) + if result['state'] == 'SUCCESS': + self.session.authenticated = True + self.session.root = True + return True + else: + raise Exception("failed to login {0}".format(email)) diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..6fe6a49 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = AccountCreation +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/_templates/globaltoc.html b/docs/source/_templates/globaltoc.html new file mode 100644 index 0000000..2099880 --- /dev/null +++ b/docs/source/_templates/globaltoc.html @@ -0,0 +1,13 @@ + diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..ed80ba4 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/stable/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys +sys.path.insert(0, os.path.abspath('../..')) + + +# -- Project information ----------------------------------------------------- + +project = 'Coto' +copyright = '2018, Sentia MPC B.V.' +author = 'Sentia MPC B.V.' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = '' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = ['.rst'] + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = ['_build', 'node_modules', '.serverless', '.vscode', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +import guzzle_sphinx_theme + +extensions.append("guzzle_sphinx_theme") +html_translator_class = 'guzzle_sphinx_theme.HTMLTranslator' +html_theme_path = guzzle_sphinx_theme.html_theme_path() +html_theme = 'guzzle_sphinx_theme' +# Guzzle theme options (see theme.conf for more information) + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'cotodoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'coto.tex', 'Coto Documentation', + 'Sentia MPC B.V.', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'coto', 'Coto Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'coto', 'Coto Documentation', + author, 'coto', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Extension configuration ------------------------------------------------- + + + +html_show_sourcelink = False +html_sidebars = { + '**': ['logo-text.html', + 'globaltoc.html', + 'searchbox.html'] +} + +autoclass_content = 'both' diff --git a/docs/source/guide/example.rst b/docs/source/guide/example.rst new file mode 100644 index 0000000..8d64a06 --- /dev/null +++ b/docs/source/guide/example.rst @@ -0,0 +1,57 @@ +####### +Example +####### + +Login as Root +------------- + +.. code-block:: python + + import coto + + session = coto.Session( + email = "email@example.com", + password = "s3cr3t_p4ssw0rd!", + mfa_secret = "xSECRETxMFAxSEEDxASDFASASAASDFASFDASDF" + ) + + +Federated Login +--------------- + +.. code-block:: python + + import boto3 + import coto + + session = coto.Session( + boto_session = boto3.Session() + ) + + +List Tax Registrations +---------------------- + +.. code-block:: python + + billing = session.client("billing") + billing.list_tax_registrations() + + +Set Root MFA Device +------------------- + +*Requires root login!* + +Please don't forget to safely store the value of ``virtual_mfa['base32StringSeed']``, or you will be locked out of your account! +You could consider encrypting it using KMS and storing it in S3 or a DynamoDB table. + +.. code-block:: python + + iam = session.client("iam") + virtual_mfa = iam.create_virtual_mfa_device() + # store ``virtual_mfa['base32StringSeed']`` somewhere! + iam.enable_mfa_device( + SerialNumber = virtual_mfa['serialNumber'], + Base32StringSeed = virtual_mfa['base32StringSeed'] + ) diff --git a/docs/source/guide/index.rst b/docs/source/guide/index.rst new file mode 100644 index 0000000..f25dea6 --- /dev/null +++ b/docs/source/guide/index.rst @@ -0,0 +1,6 @@ +User Guide +========== + +.. toctree:: + + example diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..48075a7 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,34 @@ +AWS Management Console Client Documentation +=========================================== + +Some things don't have a documented API but can be set using the AWS +Mananagement Console. Using this client we access the undocumented REST APIs +that power the AWS Management Console. + + +User Guide +---------- + +.. toctree:: + + guide/index + + +API Reference +------------- + +Services +~~~~~~~~ + +.. toctree:: + :maxdepth: 3 + + reference/services/index + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/reference/services/billing.rst b/docs/source/reference/services/billing.rst new file mode 100644 index 0000000..edd1369 --- /dev/null +++ b/docs/source/reference/services/billing.rst @@ -0,0 +1,6 @@ +Billing +======= + +.. autoclass:: coto.clients.Billing + :members: + :undoc-members: diff --git a/docs/source/reference/services/iam.rst b/docs/source/reference/services/iam.rst new file mode 100644 index 0000000..61619bb --- /dev/null +++ b/docs/source/reference/services/iam.rst @@ -0,0 +1,6 @@ +Iam +======= + +.. autoclass:: coto.clients.Iam + :members: + :undoc-members: diff --git a/docs/source/reference/services/index.rst b/docs/source/reference/services/index.rst new file mode 100644 index 0000000..991b809 --- /dev/null +++ b/docs/source/reference/services/index.rst @@ -0,0 +1,13 @@ +Available Services +================== + +.. toctree:: + :maxdepth: 2 + :glob: + + * + + +.. automodule:: coto + :members: + diff --git a/readthedocs.yml b/readthedocs.yml new file mode 100644 index 0000000..df5c7e9 --- /dev/null +++ b/readthedocs.yml @@ -0,0 +1,5 @@ +formats: + - none +python: + setup_py_install: true + diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..9a0e27f --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,4 @@ +Sphinx>=1.3 +guzzle_sphinx_theme>=0.7 +-rrequirements.txt + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..efafca9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +requests +furl +ansicolors +beautifulsoup4 +pyotp diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9886c7c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[bdist_wheel] +# This flag says to generate wheels that support both Python 2 and Python +# 3. If your code will not run unchanged on both Python 2 and 3, you will +# need to generate separate wheels for each Python version that you +# support. +universal=1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..044a808 --- /dev/null +++ b/setup.py @@ -0,0 +1,48 @@ +from setuptools import setup, find_packages + +setup( + name = "coto", + url = "https://github.com/sentialabs/coto", + description = "Undocumented AWS Mananagement Console API Client", + long_description_content_type='text/x-rst', + long_description = """ + Some things don not have a documented API but can be set using the + AWS Mananagement Console. Using this client we access the undocumented + REST APIs that power the AWS Management Console. + """, + author = "Sentia MPC B.V.", + author_email = "info@sentia.com", + license = "Apache", + version = "0.0.0", + packages = find_packages(), + install_requires = [ + 'requests', + 'furl', + 'ansicolors', + 'beautifulsoup4', + 'pyotp', + ], + classifiers=[ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + 'Development Status :: 3 - Alpha', + + # Indicate who your project is intended for + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + + # Pick your license as you wish (should match "license" above) + 'License :: OSI Approved :: Apache Software License', + + # Specify the Python versions you support here. In particular, ensure + # that you indicate whether you support Python 2, Python 3 or both. + 'Programming Language :: Python :: 3 :: Only', + ], + keywords="aws boto", + project_urls={ + 'Documentation': 'http://coto.readthedocs.io/', + 'Source': 'https://github.com/sentialabs/coto', + }, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..6373579 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +import unittest +from unittest import mock + +class BaseTestCase(unittest.TestCase): + pass diff --git a/tests/clients/__init__.py b/tests/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/session/__init__.py b/tests/session/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/session/test_session.py b/tests/session/test_session.py new file mode 100644 index 0000000..8119763 --- /dev/null +++ b/tests/session/test_session.py @@ -0,0 +1,9 @@ +from tests import mock, BaseTestCase +import coto + +class TestSession(BaseTestCase): + + def test_plain(self): + session = coto.Session() + + self.assertEqual(False, session.debug)