diff --git a/.isort.cfg b/.isort.cfg index 1ed3a39cb17..b8795de6a5c 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,2 @@ [settings] -known_third_party = PIL,babel,braintree,celery,dj_database_url,dj_email_url,django,django_cache_url,django_countries,django_filters,django_measurement,django_prices,django_prices_openexchangerates,django_prices_vatlayer,draftjs_sanitizer,faker,freezegun,geolite2,google_measurement_protocol,graphene,graphene_django,graphene_federation,graphql,graphql_jwt,graphql_relay,html_to_draftjs,i18naddress,jaeger_client,jwt,markdown,measurement,mptt,oauthlib,openpyxl,opentracing,petl,phonenumber_field,phonenumbers,pkg_resources,prices,promise,pytest,pytz,razorpay,requests,sentry_sdk,storages,stripe,templated_email,text_unidecode,tqdm,versatileimagefield +known_third_party = PIL,babel,braintree,celery,dj_database_url,dj_email_url,django,django_cache_url,django_countries,django_filters,django_measurement,django_prices,django_prices_openexchangerates,django_prices_vatlayer,draftjs_sanitizer,faker,freezegun,geolite2,google_measurement_protocol,graphene,graphene_django,graphene_federation,graphql,graphql_relay,html_to_draftjs,i18naddress,jaeger_client,jwt,markdown,measurement,mptt,oauthlib,openpyxl,opentracing,petl,phonenumber_field,phonenumbers,pkg_resources,prices,promise,pytest,pytimeparse,pytz,razorpay,requests,sentry_sdk,storages,stripe,templated_email,text_unidecode,tqdm,versatileimagefield diff --git a/CHANGELOG.md b/CHANGELOG.md index ec32d027cfe..e44d4e91fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ All notable, unreleased changes to this project will be documented in this file. - Add products csv export - #5255 by @IKarbowiak - Explicit country assignment in default shipping zones - #5736 by @maarcingebala - Drop `json_content` field from the `Menu` model - #5761 by @maarcingebala +- Refactor JWT support - #5734 by @korycins +- Strip warehouse name in mutations - #5766 by @koradon +- Add missing OrderEvents during checkout flow - #5684 by @koradon ## 2.10.1 diff --git a/poetry.lock b/poetry.lock index 4630d88e801..e1462454f56 100644 --- a/poetry.lock +++ b/poetry.lock @@ -39,7 +39,7 @@ description = "ASGI specs, helper code, and adapters" name = "asgiref" optional = false python-versions = ">=3.5" -version = "3.2.9" +version = "3.2.10" [package.extras] tests = ["pytest", "pytest-asyncio"] @@ -148,10 +148,10 @@ description = "The AWS SDK for Python" name = "boto3" optional = false python-versions = "*" -version = "1.14.4" +version = "1.14.6" [package.dependencies] -botocore = ">=1.17.4,<1.18.0" +botocore = ">=1.17.6,<1.18.0" jmespath = ">=0.7.1,<1.0.0" s3transfer = ">=0.3.0,<0.4.0" @@ -161,7 +161,7 @@ description = "Low-level, data-driven core of boto 3." name = "botocore" optional = false python-versions = "*" -version = "1.17.4" +version = "1.17.6" [package.dependencies] docutils = ">=0.10,<0.16" @@ -510,20 +510,6 @@ Django = ">=2.0" django-debug-toolbar = ">=2.0" graphene-django = ">=2.0.0" -[[package]] -category = "main" -description = "JSON Web Token for Django GraphQL" -name = "django-graphql-jwt" -optional = false -python-versions = "*" -version = "0.3.0" - -[package.dependencies] -Django = ">=1.11" -PyJWT = ">=1.5.0" -graphene-django = ">=2.0.0" -graphql-core = ">=2.1,<3" - [[package]] category = "main" description = "script tag with additional attributes for django.forms.Media" @@ -822,10 +808,10 @@ description = "Google API client core library" name = "google-api-core" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.20.1" +version = "1.21.0" [package.dependencies] -google-auth = ">=1.14.0,<2.0dev" +google-auth = ">=1.18.0,<2.0dev" googleapis-common-protos = ">=1.6.0,<2.0dev" protobuf = ">=3.12.0" pytz = "*" @@ -844,7 +830,7 @@ description = "Google Authentication Library" name = "google-auth" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.17.2" +version = "1.18.0" [package.dependencies] cachetools = ">=2.0.0,<5.0" @@ -1374,7 +1360,7 @@ description = "Python version of Google's common library for parsing, formatting name = "phonenumberslite" optional = false python-versions = "*" -version = "8.12.5" +version = "8.12.6" [[package]] category = "main" @@ -1784,6 +1770,14 @@ text-unidecode = ">=1.3" [package.extras] unidecode = ["Unidecode (>=1.1.1)"] +[[package]] +category = "main" +description = "Time expression parser" +name = "pytimeparse" +optional = false +python-versions = "*" +version = "1.1.8" + [[package]] category = "main" description = "World timezone definitions, modern and historical" @@ -1836,7 +1830,7 @@ description = "Python HTTP for Humans." name = "requests" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.23.0" +version = "2.24.0" [package.dependencies] certifi = ">=2017.4.17" @@ -1885,7 +1879,7 @@ description = "Python client for Sentry (https://getsentry.com)" name = "sentry-sdk" optional = false python-versions = "*" -version = "0.14.4" +version = "0.15.1" [package.dependencies] certifi = "*" @@ -2139,7 +2133,7 @@ marker = "sys_platform != \"win32\"" name = "uwsgi" optional = false python-versions = "*" -version = "2.0.19" +version = "2.0.19.1" [[package]] category = "dev" @@ -2244,7 +2238,7 @@ idna = ">=2.0" multidict = ">=4.0" [metadata] -content-hash = "4cc8adf6a992f59053250c83dc117177e9a3c74dc6ef81172176caf593dfc236" +content-hash = "cc5ec869de4a06c72d1085e08aeb0a98c5553d3fa466903504d25b3e00a902e7" python-versions = "~3.8" [metadata.files] @@ -2265,8 +2259,8 @@ appdirs = [ {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] asgiref = [ - {file = "asgiref-3.2.9-py3-none-any.whl", hash = "sha256:f803d8b4962cc338d48a72fa498c52f913b160eb16712e2ecdf2a81904daead9"}, - {file = "asgiref-3.2.9.tar.gz", hash = "sha256:7ea1922cfd63c4ac7687069f8bb0e7768ab9b7fc78ff227577d4240b52d6cb7a"}, + {file = "asgiref-3.2.10-py3-none-any.whl", hash = "sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed"}, + {file = "asgiref-3.2.10.tar.gz", hash = "sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a"}, ] astroid = [ {file = "astroid-2.4.2-py3-none-any.whl", hash = "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386"}, @@ -2302,12 +2296,12 @@ black = [ {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, ] boto3 = [ - {file = "boto3-1.14.4-py2.py3-none-any.whl", hash = "sha256:26c25a5d8483dbc5f7e1eeff9fadc6f5a200778ee642f8f5b827d5c78c4c5ca2"}, - {file = "boto3-1.14.4.tar.gz", hash = "sha256:91f4c004bd3726007dd55d271a0e25ee5bf4d4861a4bddee318c23750773a4d0"}, + {file = "boto3-1.14.6-py2.py3-none-any.whl", hash = "sha256:f02c0c02f632285da124e560934145de64690000bf6348df8f1eb45239f0e9df"}, + {file = "boto3-1.14.6.tar.gz", hash = "sha256:6a9cdab2db28330ffa3e6f08bb2bc07bc757d2019e4acf0c8376b72c63e7cc6b"}, ] botocore = [ - {file = "botocore-1.17.4-py2.py3-none-any.whl", hash = "sha256:25d568c4adff31f6f717a7679f799615946cc4ec3a0466ec287ac26dd11c153e"}, - {file = "botocore-1.17.4.tar.gz", hash = "sha256:59cd8fda5f55e7c334efe514859b7a6a9e92aa00aef0c3ef65f20a2c2bf525ae"}, + {file = "botocore-1.17.6-py2.py3-none-any.whl", hash = "sha256:c8b5143e2eaac20ce0d7238fd8ef33f7969139ccd616edb54fd3a482cdfd0e6c"}, + {file = "botocore-1.17.6.tar.gz", hash = "sha256:a5737a5215f9db23344752a4d2a43646c104e6d500a2d6f9409624d2e58c92f1"}, ] braintree = [ {file = "braintree-4.1.0-py2.py3-none-any.whl", hash = "sha256:152fa9e3b30a7757db0061e1c022c944216bf881209376b28a3f07043cff009b"}, @@ -2470,10 +2464,6 @@ django-filter = [ django-graphiql-debug-toolbar = [ {file = "django-graphiql-debug-toolbar-0.1.4.tar.gz", hash = "sha256:946fba21418b3fd57e7e357d4cd110fb7881e08d3b7a0a8a00400fa2e75965f5"}, ] -django-graphql-jwt = [ - {file = "django-graphql-jwt-0.3.0.tar.gz", hash = "sha256:c9c611d98510b57146c21484478eae793249553af3766a593e8f25ce73d3255a"}, - {file = "django_graphql_jwt-0.3.0-py2.py3-none-any.whl", hash = "sha256:bcf0882a492e2148cba7cba3c39cf57834f736a2c289b66852239faf9b59b61e"}, -] django-js-asset = [ {file = "django-js-asset-1.2.2.tar.gz", hash = "sha256:c163ae80d2e0b22d8fb598047cd0dcef31f81830e127cfecae278ad574167260"}, {file = "django_js_asset-1.2.2-py2.py3-none-any.whl", hash = "sha256:8ec12017f26eec524cab436c64ae73033368a372970af4cf42d9354fcb166bdd"}, @@ -2567,12 +2557,12 @@ gitpython = [ {file = "GitPython-3.1.3.tar.gz", hash = "sha256:e107af4d873daed64648b4f4beb89f89f0cfbe3ef558fc7821ed2331c2f8da1a"}, ] google-api-core = [ - {file = "google-api-core-1.20.1.tar.gz", hash = "sha256:6b757736bbc699db858794e9b71e2bbf17996075773a40551ef5e6b0fad2a2f9"}, - {file = "google_api_core-1.20.1-py2.py3-none-any.whl", hash = "sha256:b310709c325f5f9acee8feb2344c76d23577a07f4c6f4a0272c5e39d09850827"}, + {file = "google-api-core-1.21.0.tar.gz", hash = "sha256:fea9a434068406ddabe2704988d24d6c5bde3ecfc40823a34f43892d017b14f6"}, + {file = "google_api_core-1.21.0-py2.py3-none-any.whl", hash = "sha256:7b65e8e5ee59bd7517eab2bf9b3008e7b50fd9fb591d4efd780ead6859cd904b"}, ] google-auth = [ - {file = "google-auth-1.17.2.tar.gz", hash = "sha256:e634b649967d83c02dd386ecae9ce4a571528d59d51a4228757e45f5404a060b"}, - {file = "google_auth-1.17.2-py2.py3-none-any.whl", hash = "sha256:25d3c4e457db5504c62b3e329e8e67d2c29a0cecec3aa5347ced030d8700a75d"}, + {file = "google-auth-1.18.0.tar.gz", hash = "sha256:d6b390d3bb0969061ffec7e5766c45c1b39e13c302691e35029f1ad1ccd8ca3b"}, + {file = "google_auth-1.18.0-py2.py3-none-any.whl", hash = "sha256:5e3f540b7b0b892000d542cea6b818b837c230e9a4db9337bb2973bcae0fc078"}, ] google-cloud-core = [ {file = "google-cloud-core-1.3.0.tar.gz", hash = "sha256:878f9ad080a40cdcec85b92242c4b5819eeb8f120ebc5c9f640935e24fc129d8"}, @@ -2833,8 +2823,8 @@ petl = [ {file = "petl-1.3.0.tar.gz", hash = "sha256:943a6e3211616d25ebc4dcd900ca4f96f53ccd9dbb612271a23a7efdaf3f8c00"}, ] phonenumberslite = [ - {file = "phonenumberslite-8.12.5-py2.py3-none-any.whl", hash = "sha256:7e101c2d0be6ead83e2706c355aaba236df97e9a7b78aed5bda5d4b0eee11178"}, - {file = "phonenumberslite-8.12.5.tar.gz", hash = "sha256:6e1d31d8a5ad1fe280c3f7ba5b9c08da82245d59946ee06856712ef704f59e19"}, + {file = "phonenumberslite-8.12.6-py2.py3-none-any.whl", hash = "sha256:f898bff9cb2fe6a0f6e6d6d8d27e550acf77af4cf20ec22f4f02c0a399ae6f39"}, + {file = "phonenumberslite-8.12.6.tar.gz", hash = "sha256:19c8f083676061ca5848104aaecfd0d6378d1fc9b37d3777a8636fce05bca6d1"}, ] pillow = [ {file = "Pillow-7.1.2-cp35-cp35m-macosx_10_10_intel.whl", hash = "sha256:ae2b270f9a0b8822b98655cb3a59cdb1bd54a34807c6c56b76dd2e786c3b7db3"}, @@ -3055,6 +3045,10 @@ python-magic-bin = [ python-slugify = [ {file = "python-slugify-4.0.0.tar.gz", hash = "sha256:a8fc3433821140e8f409a9831d13ae5deccd0b033d4744d94b31fea141bdd84c"}, ] +pytimeparse = [ + {file = "pytimeparse-1.1.8-py2.py3-none-any.whl", hash = "sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd"}, + {file = "pytimeparse-1.1.8.tar.gz", hash = "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a"}, +] pytz = [ {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, @@ -3104,8 +3098,8 @@ regex = [ {file = "regex-2020.6.8.tar.gz", hash = "sha256:e9b64e609d37438f7d6e68c2546d2cb8062f3adb27e6336bc129b51be20773ac"}, ] requests = [ - {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, - {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, + {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, + {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, ] rsa = [ {file = "rsa-4.6-py3-none-any.whl", hash = "sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233"}, @@ -3120,8 +3114,8 @@ s3transfer = [ {file = "s3transfer-0.3.3.tar.gz", hash = "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db"}, ] sentry-sdk = [ - {file = "sentry-sdk-0.14.4.tar.gz", hash = "sha256:0e5e947d0f7a969314aa23669a94a9712be5a688ff069ff7b9fc36c66adc160c"}, - {file = "sentry_sdk-0.14.4-py2.py3-none-any.whl", hash = "sha256:799a8bf76b012e3030a881be00e97bc0b922ce35dde699c6537122b751d80e2c"}, + {file = "sentry-sdk-0.15.1.tar.gz", hash = "sha256:3ac0c430761b3cb7682ce612151d829f8644bb3830d4e530c75b02ceb745ff49"}, + {file = "sentry_sdk-0.15.1-py2.py3-none-any.whl", hash = "sha256:06825c15a78934e78941ea25910db71314c891608a46492fc32c15902c6b2119"}, ] singledispatch = [ {file = "singledispatch-3.4.0.3-py2.py3-none-any.whl", hash = "sha256:833b46966687b3de7f438c761ac475213e53b306740f1abfaa86e1d1aae56aa8"}, @@ -3234,7 +3228,7 @@ urllib3 = [ {file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"}, ] uwsgi = [ - {file = "uWSGI-2.0.19.tar.gz", hash = "sha256:a2d9e3ad86cb4fdc69ad58ba39c2b6f90a950f8a015d9075bc8d18804f4af8da"}, + {file = "uWSGI-2.0.19.1.tar.gz", hash = "sha256:faa85e053c0b1be4d5585b0858d3a511d2cd10201802e8676060fd0a109e5869"}, ] vcrpy = [ {file = "vcrpy-4.0.2-py2.py3-none-any.whl", hash = "sha256:c4ddf1b92c8a431901c56a1738a2c797d965165a96348a26f4b2bbc5fa6d36d9"}, diff --git a/pyproject.toml b/pyproject.toml index 1fd5e17dcd8..15102e04139 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,10 +19,9 @@ braintree = "~4.1.0" celery = { version = "^4.4.5", extras = ["redis"] } dj-database-url = "^0" dj-email-url = "^1" -django = "^3.0.6" +django = "3.0.6" django-countries = "^6.1" django-filter = "^2.3" -django-graphql-jwt = "0.3.0" # https://github.com/mirumee/saleor/issues/4652 django-measurement = "^3.0" django-mptt = "^0" django-phonenumber-field = "^4.0" @@ -63,6 +62,8 @@ oauthlib = "^3.0" jaeger-client = "^4.3.0" openpyxl = "^3.0.3" django-cache-url = "^3.1.2" +pyjwt = "^1.7.1" +pytimeparse = "^1.1.8" [tool.poetry.dev-dependencies] black = "19.10b0" diff --git a/requirements.txt b/requirements.txt index abe8984f8fb..a80e85a9bcb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ amqp==2.6.0 aniso8601==7.0.0 -asgiref==3.2.9 +asgiref==3.2.10 babel==2.8.0 beautifulsoup4==4.7.1 billiard==3.6.3.0 -boto3==1.14.4 -botocore==1.17.4 +boto3==1.14.6 +botocore==1.17.6 braintree==4.1.0 cachetools==4.1.0 cairocffi==1.1.0 @@ -23,7 +23,6 @@ django-appconf==1.0.4 django-cache-url==3.1.2 django-countries==6.1.2 django-filter==2.3.0 -django-graphql-jwt==0.3.0 django-js-asset==1.2.2 django-measurement==3.2.3 django-mptt==0.11.0 @@ -42,8 +41,8 @@ et-xmlfile==1.0.1 faker==4.1.1 freezegun==0.3.15 future==0.18.2 -google-api-core==1.20.1 -google-auth==1.17.2 +google-api-core==1.21.0 +google-auth==1.18.0 google-cloud-core==1.3.0 google-cloud-storage==1.29.0 google-i18n-address==2.3.5 @@ -73,7 +72,7 @@ oauthlib==3.1.0 openpyxl==3.0.3 opentracing==2.3.0 petl==1.3.0 -phonenumberslite==8.12.5 +phonenumberslite==8.12.6 pillow==7.1.2 prices==1.0.0 promise==2.3 @@ -88,14 +87,15 @@ pyphen==0.9.5 python-dateutil==2.8.1 python-magic==0.4.18 python-magic-bin==0.4.14; sys_platform == "win32" +pytimeparse==1.1.8 pytz==2020.1 razorpay==1.2.0 redis==3.5.3 -requests==2.23.0 +requests==2.24.0 rsa==4.6; python_version >= "3" rx==1.6.1 s3transfer==0.3.3 -sentry-sdk==0.14.4 +sentry-sdk==0.15.1 singledispatch==3.4.0.3 six==1.15.0 soupsieve==2.0.1 @@ -110,7 +110,7 @@ tornado==6.0.4 tqdm==4.46.1 typing==3.7.4.1 urllib3==1.25.9 -uwsgi==2.0.19; sys_platform != "win32" +uwsgi==2.0.19.1; sys_platform != "win32" vine==1.3.0 weasyprint==51 webencodings==0.5.1 diff --git a/requirements_dev.txt b/requirements_dev.txt index e0aee5ff324..8ea4ed9c25b 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -2,7 +2,7 @@ amqp==2.6.0 aniso8601==7.0.0 apipkg==1.5 appdirs==1.4.4 -asgiref==3.2.9 +asgiref==3.2.10 astroid==2.4.2 atomicwrites==1.4.0; sys_platform == "win32" attrs==19.3.0 @@ -11,8 +11,8 @@ beautifulsoup4==4.7.1 beautifultable==0.7.0 billiard==3.6.3.0 black==19.10b0 -boto3==1.14.4 -botocore==1.17.4 +boto3==1.14.6 +botocore==1.17.6 braintree==4.1.0 cachetools==4.1.0 cairocffi==1.1.0 @@ -40,7 +40,6 @@ django-debug-toolbar-request-history==0.1.3 django-extensions==2.2.9 django-filter==2.3.0 django-graphiql-debug-toolbar==0.1.4 -django-graphql-jwt==0.3.0 django-js-asset==1.2.2 django-measurement==3.2.3 django-mptt==0.11.0 @@ -65,8 +64,8 @@ freezegun==0.3.15 future==0.18.2 gitdb==4.0.5 gitpython==3.1.3 -google-api-core==1.20.1 -google-auth==1.17.2 +google-api-core==1.21.0 +google-auth==1.18.0 google-cloud-core==1.3.0 google-cloud-storage==1.29.0 google-i18n-address==2.3.5 @@ -109,7 +108,7 @@ opentracing==2.3.0 packaging==20.4 pathspec==0.8.0 petl==1.3.0 -phonenumberslite==8.12.5 +phonenumberslite==8.12.6 pillow==7.1.2 pluggy==0.13.1 pre-commit==2.5.1 @@ -144,16 +143,17 @@ python-dateutil==2.8.1 python-magic==0.4.18 python-magic-bin==0.4.14; sys_platform == "win32" python-slugify==4.0.0 +pytimeparse==1.1.8 pytz==2020.1 pyyaml==5.3.1 razorpay==1.2.0 redis==3.5.3 regex==2020.6.8 -requests==2.23.0 +requests==2.24.0 rsa==4.6; python_version >= "3" rx==1.6.1 s3transfer==0.3.3 -sentry-sdk==0.14.4 +sentry-sdk==0.15.1 singledispatch==3.4.0.3 six==1.15.0 smmap==3.0.4 @@ -175,7 +175,7 @@ typed-ast==1.4.1 typing==3.7.4.1 typing-extensions==3.7.4.2 urllib3==1.25.9 -uwsgi==2.0.19; sys_platform != "win32" +uwsgi==2.0.19.1; sys_platform != "win32" vcrpy==4.0.2 vine==1.3.0 virtualenv==20.0.23 diff --git a/saleor/account/error_codes.py b/saleor/account/error_codes.py index 9a39bb2e347..b8c2211c5c9 100644 --- a/saleor/account/error_codes.py +++ b/saleor/account/error_codes.py @@ -27,6 +27,11 @@ class AccountErrorCode(Enum): PASSWORD_TOO_SIMILAR = "password_too_similar" REQUIRED = "required" UNIQUE = "unique" + JWT_SIGNATURE_EXPIRED = "signature_has_expired" + JWT_INVALID_TOKEN = "invalid_token" + JWT_DECODE_ERROR = "decode_error" + JWT_MISSING_TOKEN = "missing_token" + JWT_INVALID_CSRF_TOKEN = "invalid_csrf_token" class PermissionGroupErrorCode(Enum): diff --git a/saleor/account/migrations/0046_user_jwt_token_key.py b/saleor/account/migrations/0046_user_jwt_token_key.py new file mode 100644 index 00000000000..d9c346106ad --- /dev/null +++ b/saleor/account/migrations/0046_user_jwt_token_key.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.6 on 2020-06-08 13:24 + +import django.utils.crypto +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("account", "0045_auto_20200427_0425"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="jwt_token_key", + field=models.CharField( + default=django.utils.crypto.get_random_string, max_length=12 + ), + ), + ] diff --git a/saleor/account/models.py b/saleor/account/models.py index d3fc63ce42a..80562fac32d 100644 --- a/saleor/account/models.py +++ b/saleor/account/models.py @@ -11,6 +11,7 @@ from django.db.models import Q, Value from django.forms.models import model_to_dict from django.utils import timezone +from django.utils.crypto import get_random_string from django_countries.fields import Country, CountryField from phonenumber_field.modelfields import PhoneNumber, PhoneNumberField from versatileimagefield.fields import VersatileImageField @@ -148,6 +149,7 @@ class User(PermissionsMixin, ModelWithMetadata, AbstractBaseUser): Address, related_name="+", null=True, blank=True, on_delete=models.SET_NULL ) avatar = VersatileImageField(upload_to="user-avatars", blank=True, null=True) + jwt_token_key = models.CharField(max_length=12, default=get_random_string) USERNAME_FIELD = "email" diff --git a/saleor/account/utils.py b/saleor/account/utils.py index 681c0200e4d..c869259e683 100644 --- a/saleor/account/utils.py +++ b/saleor/account/utils.py @@ -1,9 +1,3 @@ -import jwt -from django.conf import settings -from django.core.exceptions import ValidationError -from django.utils import timezone - -from ..account.error_codes import AccountErrorCode from ..checkout import AddressType from ..core.utils import create_thumbnails from ..plugins.manager import get_plugins_manager @@ -78,27 +72,3 @@ def remove_staff_member(staff): staff.save() else: staff.delete() - - -def create_jwt_token(token_data): - expiration_date = timezone.now() + timezone.timedelta(hours=1) - token_kwargs = {"exp": expiration_date} - token_kwargs.update(token_data) - token = jwt.encode(token_kwargs, settings.SECRET_KEY, algorithm="HS256").decode() - return token - - -def decode_jwt_token(token): - try: - decoded_token = jwt.decode( - token.encode(), settings.SECRET_KEY, algorithms=["HS256"] - ) - except jwt.PyJWTError: - raise ValidationError( - { - "token": ValidationError( - "Invalid or expired token.", code=AccountErrorCode.INVALID - ) - } - ) - return decoded_token diff --git a/saleor/checkout/tests/test_checkout.py b/saleor/checkout/tests/test_checkout.py index a32bbb2d5c1..8556cf8b99e 100644 --- a/saleor/checkout/tests/test_checkout.py +++ b/saleor/checkout/tests/test_checkout.py @@ -86,12 +86,11 @@ def test_last_change_update_foregin_key(checkout, shipping_method): assert checkout.last_change == pytz.utc.localize(frozen_datetime()) -@pytest.mark.parametrize("is_anonymous_user", (True, False)) -def test_create_order_creates_expected_events( - request_checkout_with_item, customer_user, shipping_method, is_anonymous_user +def test_create_order_captured_payment_creates_expected_events( + request_checkout_with_item, customer_user, shipping_method, payment_txn_captured, ): checkout = request_checkout_with_item - checkout_user = None if is_anonymous_user else customer_user + checkout_user = customer_user # Ensure not events are existing prior assert not OrderEvent.objects.exists() @@ -102,6 +101,7 @@ def test_create_order_creates_expected_events( checkout.billing_address = customer_user.default_billing_address checkout.shipping_address = customer_user.default_shipping_address checkout.shipping_method = shipping_method + checkout.payments.add(payment_txn_captured) checkout.save() # Place checkout @@ -113,35 +113,298 @@ def test_create_order_creates_expected_events( tracking_code="tracking_code", discounts=None, ), - user=customer_user if not is_anonymous_user else AnonymousUser(), + user=customer_user, redirect_url="https://www.example.com", ) flush_post_commit_hooks() # Ensure only two events were created, and retrieve them - placement_event, email_sent_event = order.events.all() # type: OrderEvent + order_events = order.events.all() + + ( + order_placed_event, + payment_captured_event, + order_fully_paid_event, + payment_email_sent_event, + order_placed_email_sent_event, + ) = order_events # type: OrderEvent # Ensure the correct order event was created - assert placement_event.type == OrderEvents.PLACED # is the event the expected type - assert placement_event.user == checkout_user # is the user anonymous/ the customer - assert placement_event.order is order # is the associated backref order valid + # is the event the expected type + assert order_placed_event.type == OrderEvents.PLACED + # is the user anonymous/ the customer + assert order_placed_event.user == checkout_user + # is the associated backref order valid + assert order_placed_event.order is order + # ensure a date was set + assert order_placed_event.date + # should not have any additional parameters + assert not order_placed_event.parameters + + # Ensure the correct order event was created + # is the event the expected type + assert payment_captured_event.type == OrderEvents.PAYMENT_CAPTURED + # is the user anonymous/ the customer + assert payment_captured_event.user == checkout_user + # is the associated backref order valid + assert payment_captured_event.order is order + # ensure a date was set + assert payment_captured_event.date + # should not have any additional parameters + assert "amount" in payment_captured_event.parameters.keys() + assert "payment_id" in payment_captured_event.parameters.keys() + assert "payment_gateway" in payment_captured_event.parameters.keys() + + # Ensure the correct order event was created + # is the event the expected type + assert order_fully_paid_event.type == OrderEvents.ORDER_FULLY_PAID + # is the user anonymous/ the customer + assert order_fully_paid_event.user == checkout_user + # is the associated backref order valid + assert order_fully_paid_event.order is order + # ensure a date was set + assert order_fully_paid_event.date + # should not have any additional parameters + assert not order_fully_paid_event.parameters + + # Ensure the correct email sent event was created + # should be email sent event + assert payment_email_sent_event.type == OrderEvents.EMAIL_SENT + # ensure the user is none or valid + assert payment_email_sent_event.user == checkout_user + # ensure the mail event is related to order + assert payment_email_sent_event.order is order + # ensure a date was set + assert payment_email_sent_event.date + # ensure the correct parameters were set + assert payment_email_sent_event.parameters == { + "email": order.get_customer_email(), + "email_type": OrderEventsEmails.PAYMENT, + } + + # Ensure the correct email sent event was created + # should be email sent event + assert order_placed_email_sent_event.type == OrderEvents.EMAIL_SENT + # ensure the user is none or valid + assert order_placed_email_sent_event.user == checkout_user + # ensure the mail event is related to order + assert order_placed_email_sent_event.order is order + # ensure a date was set + assert order_placed_email_sent_event.date + # ensure the correct parameters were set + assert order_placed_email_sent_event.parameters == { + "email": order.get_customer_email(), + "email_type": OrderEventsEmails.ORDER, + } + + # Ensure the correct customer event was created if the user was not anonymous + placement_event = customer_user.events.get() # type: CustomerEvent + assert placement_event.type == CustomerEvents.PLACED_ORDER # check the event type + assert placement_event.user == customer_user # check the backref is valid + assert placement_event.order == order # check the associated order is valid assert placement_event.date # ensure a date was set assert not placement_event.parameters # should not have any additional parameters + # mock_send_staff_order_confirmation.assert_called_once_with(order.pk) + + +def test_create_order_captured_payment_creates_expected_events_anonymous_user( + request_checkout_with_item, customer_user, shipping_method, payment_txn_captured, +): + checkout = request_checkout_with_item + checkout_user = None + + # Ensure not events are existing prior + assert not OrderEvent.objects.exists() + assert not CustomerEvent.objects.exists() + + # Prepare valid checkout + checkout.user = checkout_user + checkout.email = "test@example.com" + checkout.billing_address = customer_user.default_billing_address + checkout.shipping_address = customer_user.default_shipping_address + checkout.shipping_method = shipping_method + checkout.payments.add(payment_txn_captured) + checkout.save() + + # Place checkout + order = create_order( + checkout=checkout, + order_data=prepare_order_data( + checkout=checkout, + lines=list(checkout), + tracking_code="tracking_code", + discounts=None, + ), + user=AnonymousUser(), + redirect_url="https://www.example.com", + ) + flush_post_commit_hooks() + + # Ensure only two events were created, and retrieve them + order_events = order.events.all() + + ( + order_placed_event, + payment_captured_event, + order_fully_paid_event, + payment_email_sent_event, + order_placed_email_sent_event, + ) = order_events # type: OrderEvent + + # Ensure the correct order event was created + # is the event the expected type + assert order_placed_event.type == OrderEvents.PLACED + # is the user anonymous/ the customer + assert order_placed_event.user == checkout_user + # is the associated backref order valid + assert order_placed_event.order is order + # ensure a date was set + assert order_placed_event.date + # should not have any additional parameters + assert not order_placed_event.parameters + + # Ensure the correct order event was created + # is the event the expected type + assert payment_captured_event.type == OrderEvents.PAYMENT_CAPTURED + # is the user anonymous/ the customer + assert payment_captured_event.user == checkout_user + # is the associated backref order valid + assert payment_captured_event.order is order + # ensure a date was set + assert payment_captured_event.date + # should not have any additional parameters + assert "amount" in payment_captured_event.parameters.keys() + assert "payment_id" in payment_captured_event.parameters.keys() + assert "payment_gateway" in payment_captured_event.parameters.keys() + + # Ensure the correct order event was created + # is the event the expected type + assert order_fully_paid_event.type == OrderEvents.ORDER_FULLY_PAID + # is the user anonymous/ the customer + assert order_fully_paid_event.user == checkout_user + # is the associated backref order valid + assert order_fully_paid_event.order is order + # ensure a date was set + assert order_fully_paid_event.date + # should not have any additional parameters + assert not order_fully_paid_event.parameters + + # Ensure the correct email sent event was created + # should be email sent event + assert payment_email_sent_event.type == OrderEvents.EMAIL_SENT + # ensure the user is none or valid + assert payment_email_sent_event.user == checkout_user + # ensure the mail event is related to order + assert payment_email_sent_event.order is order + # ensure a date was set + assert payment_email_sent_event.date + # ensure the correct parameters were set + assert payment_email_sent_event.parameters == { + "email": order.get_customer_email(), + "email_type": OrderEventsEmails.PAYMENT, + } + # Ensure the correct email sent event was created - assert email_sent_event.type == OrderEvents.EMAIL_SENT # should be email sent event - assert email_sent_event.user == checkout_user # ensure the user is none or valid - assert email_sent_event.order is order # ensure the mail event is related to order - assert email_sent_event.date # ensure a date was set - assert email_sent_event.parameters == { # ensure the correct parameters were set + # should be email sent event + assert order_placed_email_sent_event.type == OrderEvents.EMAIL_SENT + # ensure the user is none or valid + assert order_placed_email_sent_event.user == checkout_user + # ensure the mail event is related to order + assert order_placed_email_sent_event.order is order + # ensure a date was set + assert order_placed_email_sent_event.date + # ensure the correct parameters were set + assert order_placed_email_sent_event.parameters == { "email": order.get_customer_email(), "email_type": OrderEventsEmails.ORDER, } # Check no event was created if the user was anonymous - if is_anonymous_user: - assert not CustomerEvent.objects.exists() # should not have created any event - return # we are done testing as the user is anonymous + assert not CustomerEvent.objects.exists() # should not have created any event + + +def test_create_order_preauth_payment_creates_expected_events( + request_checkout_with_item, customer_user, shipping_method, payment_txn_preauth, +): + checkout = request_checkout_with_item + checkout_user = customer_user + + # Ensure not events are existing prior + assert not OrderEvent.objects.exists() + assert not CustomerEvent.objects.exists() + + # Prepare valid checkout + checkout.user = checkout_user + checkout.billing_address = customer_user.default_billing_address + checkout.shipping_address = customer_user.default_shipping_address + checkout.shipping_method = shipping_method + checkout.payments.add(payment_txn_preauth) + checkout.save() + + # Place checkout + order = create_order( + checkout=checkout, + order_data=prepare_order_data( + checkout=checkout, + lines=list(checkout), + tracking_code="tracking_code", + discounts=None, + ), + user=customer_user, + redirect_url="https://www.example.com", + ) + flush_post_commit_hooks() + + # Ensure only two events were created, and retrieve them + order_events = order.events.all() + + ( + order_placed_event, + payment_authorized_event, + order_placed_email_sent_event, + ) = order_events # type: OrderEvent + + # Ensure the correct order event was created + # is the event the expected type + assert order_placed_event.type == OrderEvents.PLACED + # is the user anonymous/ the customer + assert order_placed_event.user == checkout_user + # is the associated backref order valid + assert order_placed_event.order is order + # ensure a date was set + assert order_placed_event.date + # should not have any additional parameters + assert not order_placed_event.parameters + + # Ensure the correct order event was created + # is the event the expected type + assert payment_authorized_event.type == OrderEvents.PAYMENT_AUTHORIZED + # is the user anonymous/ the customer + assert payment_authorized_event.user == checkout_user + # is the associated backref order valid + assert payment_authorized_event.order is order + # ensure a date was set + assert payment_authorized_event.date + # should not have any additional parameters + assert "amount" in payment_authorized_event.parameters.keys() + assert "payment_id" in payment_authorized_event.parameters.keys() + assert "payment_gateway" in payment_authorized_event.parameters.keys() + + # Ensure the correct email sent event was created + # should be email sent event + assert order_placed_email_sent_event.type == OrderEvents.EMAIL_SENT + # ensure the user is none or valid + assert order_placed_email_sent_event.user == checkout_user + # ensure the mail event is related to order + assert order_placed_email_sent_event.order is order + # ensure a date was set + assert order_placed_email_sent_event.date + # ensure the correct parameters were set + assert order_placed_email_sent_event.parameters == { + "email": order.get_customer_email(), + "email_type": OrderEventsEmails.ORDER, + } # Ensure the correct customer event was created if the user was not anonymous placement_event = customer_user.events.get() # type: CustomerEvent @@ -154,6 +417,93 @@ def test_create_order_creates_expected_events( # mock_send_staff_order_confirmation.assert_called_once_with(order.pk) +def test_create_order_preauth_payment_creates_expected_events_anonymous_user( + request_checkout_with_item, customer_user, shipping_method, payment_txn_preauth, +): + checkout = request_checkout_with_item + checkout_user = None + + # Ensure not events are existing prior + assert not OrderEvent.objects.exists() + assert not CustomerEvent.objects.exists() + + # Prepare valid checkout + checkout.user = checkout_user + checkout.email = "test@example.com" + checkout.billing_address = customer_user.default_billing_address + checkout.shipping_address = customer_user.default_shipping_address + checkout.shipping_method = shipping_method + checkout.payments.add(payment_txn_preauth) + checkout.save() + + # Place checkout + order = create_order( + checkout=checkout, + order_data=prepare_order_data( + checkout=checkout, + lines=list(checkout), + tracking_code="tracking_code", + discounts=None, + ), + user=AnonymousUser(), + redirect_url="https://www.example.com", + ) + flush_post_commit_hooks() + + # Ensure only two events were created, and retrieve them + order_events = order.events.all() + + ( + order_placed_event, + payment_captured_event, + order_placed_email_sent_event, + ) = order_events # type: OrderEvent + + # Ensure the correct order event was created + # is the event the expected type + assert order_placed_event.type == OrderEvents.PLACED + # is the user anonymous/ the customer + assert order_placed_event.user == checkout_user + # is the associated backref order valid + assert order_placed_event.order is order + # ensure a date was set + assert order_placed_event.date + # should not have any additional parameters + assert not order_placed_event.parameters + + # Ensure the correct order event was created + # is the event the expected type + assert payment_captured_event.type == OrderEvents.PAYMENT_AUTHORIZED + # is the user anonymous/ the customer + assert payment_captured_event.user == checkout_user + # is the associated backref order valid + assert payment_captured_event.order is order + # ensure a date was set + assert payment_captured_event.date + # should not have any additional parameters + assert "amount" in payment_captured_event.parameters.keys() + assert "payment_id" in payment_captured_event.parameters.keys() + assert "payment_gateway" in payment_captured_event.parameters.keys() + + # Ensure the correct email sent event was created + # should be email sent event + assert order_placed_email_sent_event.type == OrderEvents.EMAIL_SENT + # ensure the user is none or valid + assert order_placed_email_sent_event.user == checkout_user + # ensure the mail event is related to order + assert order_placed_email_sent_event.order is order + # ensure a date was set + assert order_placed_email_sent_event.date + # ensure the correct parameters were set + assert order_placed_email_sent_event.parameters == { + "email": order.get_customer_email(), + "email_type": OrderEventsEmails.ORDER, + } + + # Check no event was created if the user was anonymous + assert not CustomerEvent.objects.exists() # should not have created any event + + def test_create_order_insufficient_stock( request_checkout, customer_user, product_without_shipping ): diff --git a/saleor/core/auth_backend.py b/saleor/core/auth_backend.py new file mode 100644 index 00000000000..380c890b29b --- /dev/null +++ b/saleor/core/auth_backend.py @@ -0,0 +1,19 @@ +from ..account.models import User +from .jwt import get_token_from_request, get_user_from_access_token + + +class JSONWebTokenBackend: + def authenticate(self, request=None, **kwargs): + if request is None: + return None + + token = get_token_from_request(request) + if not token: + return None + return get_user_from_access_token(token) + + def get_user(self, user_id): + try: + return User.objects.get(email=user_id, is_active=True) + except User.DoesNotExist: + return None diff --git a/saleor/core/exceptions.py b/saleor/core/exceptions.py index 2102cadee50..8829435c3a1 100644 --- a/saleor/core/exceptions.py +++ b/saleor/core/exceptions.py @@ -31,3 +31,11 @@ def __init__(self, context=None): super().__init__("Can't add unpublished product.") self.context = context self.code = CheckoutErrorCode.PRODUCT_NOT_PUBLISHED + + +class PermissionDenied(Exception): + def __init__(self, message=None): + default_message = "You do not have permission to perform this action" + if message is None: + message = default_message + super().__init__(message) diff --git a/saleor/core/jwt.py b/saleor/core/jwt.py new file mode 100644 index 00000000000..912f76237f2 --- /dev/null +++ b/saleor/core/jwt.py @@ -0,0 +1,108 @@ +from datetime import datetime, timedelta +from typing import Any, Dict, Optional + +import graphene +import jwt +from django.conf import settings +from django.core.handlers.wsgi import WSGIRequest + +from ..account.models import User + +JWT_ALGORITHM = "HS256" +JWT_AUTH_HEADER = "HTTP_AUTHORIZATION" +JWT_AUTH_HEADER_PREFIX = "JWT" +JWT_ACCESS_TYPE = "access" +JWT_REFRESH_TYPE = "refresh" +JWT_REFRESH_TOKEN_COOKIE_NAME = "refreshToken" + + +def jwt_base_payload(exp_delta: Optional[timedelta] = None) -> Dict[str, Any]: + utc_now = datetime.utcnow() + payload = { + "iat": utc_now, + } + if exp_delta: + payload["exp"] = utc_now + exp_delta + return payload + + +def jwt_user_payload( + user: User, + token_type: str, + exp_delta: timedelta, + additional_payload: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + if not settings.JWT_EXPIRE: + exp_delta = None # type: ignore + + payload = jwt_base_payload(exp_delta) + payload.update( + { + "token": user.jwt_token_key, + "email": user.email, + "type": token_type, + "user_id": graphene.Node.to_global_id("User", user.id), + "is_staff": user.is_staff, + } + ) + if additional_payload: + payload.update(additional_payload) + return payload + + +def jwt_encode(payload: Dict[str, Any]) -> str: + return jwt.encode( + payload, settings.SECRET_KEY, JWT_ALGORITHM, # type: ignore + ).decode("utf-8") + + +def jwt_decode(token: str) -> Dict[str, Any]: + return jwt.decode( + token, settings.SECRET_KEY, algorithms=JWT_ALGORITHM # type: ignore + ) + + +def create_token(payload: Dict[str, Any], exp_delta: timedelta) -> str: + payload.update(jwt_base_payload(exp_delta)) + return jwt_encode(payload) + + +def create_access_token( + user: User, additional_payload: Optional[Dict[str, Any]] = None +) -> str: + payload = jwt_user_payload( + user, JWT_ACCESS_TYPE, settings.JWT_TTL_ACCESS, additional_payload + ) + return jwt_encode(payload) + + +def create_refresh_token( + user: User, additional_payload: Optional[Dict[str, Any]] = None +) -> str: + payload = jwt_user_payload( + user, JWT_REFRESH_TYPE, settings.JWT_TTL_REFRESH, additional_payload, + ) + return jwt_encode(payload) + + +def get_token_from_request(request: WSGIRequest) -> Optional[str]: + auth = request.META.get(JWT_AUTH_HEADER, "").split(maxsplit=1) + prefix = JWT_AUTH_HEADER_PREFIX + + if len(auth) != 2 or auth[0].upper() != prefix: + return None + return auth[1] + + +def get_user_from_payload(payload: Dict[str, Any]) -> Optional[User]: + user = User.objects.filter(email=payload["email"], is_active=True).first() + if user and user.jwt_token_key == payload["token"]: + return user + return None + + +def get_user_from_access_token(token: str) -> Optional[User]: + payload = jwt_decode(token) + if payload["type"] != JWT_ACCESS_TYPE: + return None + return get_user_from_payload(payload) diff --git a/saleor/core/middleware.py b/saleor/core/middleware.py index 164d63fe2e6..684a63de5a3 100644 --- a/saleor/core/middleware.py +++ b/saleor/core/middleware.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from django.conf import settings from django.contrib.sites.models import Site @@ -11,6 +12,7 @@ from ..discount.utils import fetch_discounts from ..plugins.manager import get_plugins_manager from . import analytics +from .jwt import JWT_REFRESH_TOKEN_COOKIE_NAME, jwt_decode from .utils import get_client_ip, get_country_by_ip, get_currency_for_country logger = logging.getLogger(__name__) @@ -116,3 +118,25 @@ def _plugins_middleware(request): return get_response(request) return _plugins_middleware + + +def jwt_refresh_token_middleware(get_response): + def middleware(request): + """Append generated refresh_token to response object.""" + response = get_response(request) + jwt_refresh_token = getattr(request, "refresh_token", None) + if jwt_refresh_token: + expires = None + if settings.JWT_EXPIRE: + refresh_token_payload = jwt_decode(jwt_refresh_token) + expires = datetime.utcfromtimestamp(refresh_token_payload["exp"]) + response.set_cookie( + JWT_REFRESH_TOKEN_COOKIE_NAME, + jwt_refresh_token, + expires=expires, + httponly=True, # protects token from leaking + secure=True, + ) + return response + + return middleware diff --git a/saleor/core/tests/test_auth_backend.py b/saleor/core/tests/test_auth_backend.py new file mode 100644 index 00000000000..cc9bf4284b5 --- /dev/null +++ b/saleor/core/tests/test_auth_backend.py @@ -0,0 +1,84 @@ +import jwt +import pytest +from freezegun import freeze_time +from jwt import ExpiredSignatureError, InvalidSignatureError + +from ..auth_backend import JSONWebTokenBackend +from ..jwt import ( + JWT_ACCESS_TYPE, + JWT_ALGORITHM, + create_access_token, + create_refresh_token, + jwt_user_payload, +) + + +def test_user_authenticated(rf, staff_user): + access_token = create_access_token(staff_user) + request = rf.request(HTTP_AUTHORIZATION=f"JWT {access_token}") + backend = JSONWebTokenBackend() + user = backend.authenticate(request) + assert user == staff_user + + +def test_user_deactivated(rf, staff_user): + staff_user.is_active = False + staff_user.save() + access_token = create_access_token(staff_user) + request = rf.request(HTTP_AUTHORIZATION=f"JWT {access_token}") + backend = JSONWebTokenBackend() + assert backend.authenticate(request) is None + + +def test_incorect_type_of_token(rf, staff_user): + token = create_refresh_token(staff_user) + request = rf.request(HTTP_AUTHORIZATION=f"JWT {token}") + backend = JSONWebTokenBackend() + assert backend.authenticate(request) is None + + +def test_incorrect_token(rf, staff_user, settings): + payload = jwt_user_payload(staff_user, JWT_ACCESS_TYPE, settings.JWT_TTL_ACCESS,) + token = jwt.encode(payload, "Wrong secret", JWT_ALGORITHM,).decode("utf-8") + request = rf.request(HTTP_AUTHORIZATION=f"JWT {token}") + backend = JSONWebTokenBackend() + with pytest.raises(InvalidSignatureError): + backend.authenticate(request) + + +def test_missing_token(rf, staff_user): + request = rf.request(HTTP_AUTHORIZATION="JWT ") + backend = JSONWebTokenBackend() + assert backend.authenticate(request) is None + + +def test_missing_header(rf, staff_user): + request = rf.request() + backend = JSONWebTokenBackend() + assert backend.authenticate(request) is None + + +def test_token_expired(rf, staff_user): + with freeze_time("2019-03-18 12:00:00"): + access_token = create_access_token(staff_user) + request = rf.request(HTTP_AUTHORIZATION=f"JWT {access_token}") + backend = JSONWebTokenBackend() + with pytest.raises(ExpiredSignatureError): + backend.authenticate(request) + + +def test_user_doesnt_exist(rf, staff_user): + access_token = create_access_token(staff_user) + staff_user.delete() + request = rf.request(HTTP_AUTHORIZATION=f"JWT {access_token}") + backend = JSONWebTokenBackend() + assert backend.authenticate(request) is None + + +def test_user_deactivated_token(rf, staff_user): + access_token = create_access_token(staff_user) + staff_user.jwt_token_key = "New key" + staff_user.save() + request = rf.request(HTTP_AUTHORIZATION=f"JWT {access_token}") + backend = JSONWebTokenBackend() + assert backend.authenticate(request) is None diff --git a/saleor/core/tests/test_middleware.py b/saleor/core/tests/test_middleware.py new file mode 100644 index 00000000000..3af24898f91 --- /dev/null +++ b/saleor/core/tests/test_middleware.py @@ -0,0 +1,19 @@ +from django.core.handlers.base import BaseHandler +from freezegun import freeze_time + +from ..jwt import JWT_REFRESH_TOKEN_COOKIE_NAME, create_refresh_token + + +@freeze_time("2020-03-18 12:00:00") +def test_jwt_refresh_token_middleware(rf, customer_user, settings): + refresh_token = create_refresh_token(customer_user) + settings.MIDDLEWARE = [ + "saleor.core.middleware.jwt_refresh_token_middleware", + ] + request = rf.request() + request.refresh_token = refresh_token + handler = BaseHandler() + handler.load_middleware() + response = handler.get_response(request) + cookie = response.cookies.get(JWT_REFRESH_TOKEN_COOKIE_NAME) + assert cookie.value == refresh_token diff --git a/saleor/graphql/account/mutations/account.py b/saleor/graphql/account/mutations/account.py index 4bf44f7afc9..7418c4883f1 100644 --- a/saleor/graphql/account/mutations/account.py +++ b/saleor/graphql/account/mutations/account.py @@ -1,4 +1,5 @@ import graphene +import jwt from django.conf import settings from django.contrib.auth import password_validation from django.contrib.auth.tokens import default_token_generator @@ -6,9 +7,10 @@ from ....account import emails, events as account_events, models, utils from ....account.error_codes import AccountErrorCode -from ....account.utils import create_jwt_token, decode_jwt_token from ....checkout import AddressType +from ....core.jwt import create_token, jwt_decode from ....core.utils.url import validate_storefront_url +from ....settings import JWT_TTL_REQUEST_EMAIL_CHANGE from ...account.enums import AddressTypeEnum from ...account.types import Address, AddressInput, User from ...core.mutations import BaseMutation, ModelDeleteMutation, ModelMutation @@ -408,12 +410,12 @@ def perform_mutation(cls, _root, info, **data): raise ValidationError( {"redirect_url": error}, code=AccountErrorCode.INVALID ) - token_kwargs = { + token_payload = { "old_email": user.email, "new_email": new_email, "user_pk": user.pk, } - token = create_jwt_token(token_kwargs) + token = create_token(token_payload, JWT_TTL_REQUEST_EMAIL_CHANGE) emails.send_user_change_email_url(redirect_url, user, new_email, token) return RequestEmailChange(user=user) @@ -435,13 +437,29 @@ class Meta: def check_permissions(cls, context): return context.user.is_authenticated + @classmethod + def get_token_payload(cls, token): + try: + payload = jwt_decode(token) + except jwt.PyJWTError: + raise ValidationError( + { + "token": ValidationError( + "Invalid or expired token.", + code=AccountErrorCode.JWT_INVALID_TOKEN, + ) + } + ) + return payload + @classmethod def perform_mutation(cls, _root, info, **data): user = info.context.user token = data["token"] - decoded_token = decode_jwt_token(token) - new_email = decoded_token["new_email"] - old_email = decoded_token["old_email"] + + payload = cls.get_token_payload(token) + new_email = payload["new_email"] + old_email = payload["old_email"] if models.User.objects.filter(email=new_email).exists(): raise ValidationError( diff --git a/saleor/graphql/account/mutations/base.py b/saleor/graphql/account/mutations/base.py index 9cfb0ee2da8..190821c8ae7 100644 --- a/saleor/graphql/account/mutations/base.py +++ b/saleor/graphql/account/mutations/base.py @@ -3,7 +3,6 @@ from django.contrib.auth.tokens import default_token_generator from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import transaction -from graphql_jwt.exceptions import PermissionDenied from ....account import events as account_events, models from ....account.emails import ( @@ -11,6 +10,7 @@ send_user_password_reset_email_with_url, ) from ....account.error_codes import AccountErrorCode +from ....core.exceptions import PermissionDenied from ....core.permissions import AccountPermissions from ....core.utils.url import validate_storefront_url from ....order.utils import match_orders_with_new_user @@ -18,13 +18,13 @@ from ...account.types import Address, AddressInput, User from ...core.mutations import ( BaseMutation, - CreateToken, ModelDeleteMutation, ModelMutation, validation_error_to_error_type, ) from ...core.types.common import AccountError from ...meta.deprecated.mutations import ClearMetaBaseMutation, UpdateMetaBaseMutation +from .jwt import CreateToken BILLING_ADDRESS_FIELD = "default_billing_address" SHIPPING_ADDRESS_FIELD = "default_shipping_address" @@ -49,12 +49,16 @@ class Arguments: token = graphene.String( description="A one-time token required to set the password.", required=True ) + email = graphene.String(required=True, description="Email of a user.") + password = graphene.String(required=True, description="Password of a user.") class Meta: description = ( "Sets the user's password from the token sent by email " "using the RequestPasswordReset mutation." ) + error_type_class = AccountError + error_type_field = "account_errors" @classmethod def mutate(cls, root, info, **data): diff --git a/saleor/graphql/account/mutations/jwt.py b/saleor/graphql/account/mutations/jwt.py new file mode 100644 index 00000000000..81e05b1ea29 --- /dev/null +++ b/saleor/graphql/account/mutations/jwt.py @@ -0,0 +1,265 @@ +import graphene +import jwt +from django.contrib.auth import authenticate +from django.core.exceptions import ValidationError +from django.middleware.csrf import _compare_salted_tokens, _get_new_csrf_token +from django.utils import timezone +from django.utils.crypto import get_random_string +from graphene.types.generic import GenericScalar + +from ....account.error_codes import AccountErrorCode +from ....core.jwt import ( + JWT_REFRESH_TOKEN_COOKIE_NAME, + JWT_REFRESH_TYPE, + create_access_token, + create_refresh_token, + get_user_from_payload, + jwt_decode, +) +from ...core.mutations import BaseMutation +from ...core.types.common import AccountError +from ..types import User + + +def get_payload(token): + try: + payload = jwt_decode(token) + except jwt.ExpiredSignature: + raise ValidationError( + "Signature has expired", code=AccountErrorCode.JWT_SIGNATURE_EXPIRED.value + ) + except jwt.DecodeError: + raise ValidationError( + "Error decoding signature", code=AccountErrorCode.JWT_DECODE_ERROR.value + ) + except jwt.InvalidTokenError: + raise ValidationError( + "Invalid token", code=AccountErrorCode.JWT_INVALID_TOKEN.value + ) + return payload + + +def get_user(payload): + user = get_user_from_payload(payload) + if not user: + raise ValidationError( + "Invalid token", code=AccountErrorCode.JWT_INVALID_TOKEN.value + ) + return user + + +class CreateToken(BaseMutation): + """Mutation that authenticates a user and returns token and user data.""" + + class Arguments: + email = graphene.String(required=True, description="Email of a user.") + password = graphene.String(required=True, description="Password of a user.") + + class Meta: + description = "Create JWT token." + error_type_class = AccountError + error_type_field = "account_errors" + + token = graphene.String(description="JWT token, required to authenticate.") + refresh_token = graphene.String( + description="JWT refresh token, required to re-generate access token." + ) + csrf_token = graphene.String( + description="CSRF token required to re-generate access token." + ) + user = graphene.Field(User, description="A user instance.") + + @classmethod + def get_user(cls, info, data): + user = authenticate( + request=info.context, username=data["email"], password=data["password"], + ) + if not user: + raise ValidationError( + { + "email": ValidationError( + "Please, enter valid credentials", + code=AccountErrorCode.INVALID_CREDENTIALS.value, + ) + } + ) + return user + + @classmethod + def perform_mutation(cls, root, info, **data): + user = cls.get_user(info, data) + access_token = create_access_token(user) + csrf_token = _get_new_csrf_token() + refresh_token = create_refresh_token(user, {"csrfToken": csrf_token}) + info.context.refresh_token = refresh_token + info.context._cached_user = user + user.last_login = timezone.now() + user.save(update_fields=["last_login"]) + return cls( + errors=[], + user=user, + token=access_token, + refresh_token=refresh_token, + csrf_token=csrf_token, + ) + + +class RefreshToken(BaseMutation): + """Mutation that refresh user token and returns token and user data.""" + + token = graphene.String(description="JWT token, required to authenticate.") + user = graphene.Field(User, description="A user instance.") + + class Arguments: + refresh_token = graphene.String(required=False, description="Refresh token.") + csrf_token = graphene.String( + required=False, + description=( + "CSRF token required to refresh token. This argument is " + "required when refreshToken is provided as a cookie." + ), + ) + + class Meta: + description = ( + "Refresh JWT token. Mutation tries to take refreshToken from the input." + "If it fails it will try to take refreshToken from the http-only cookie -" + f"{JWT_REFRESH_TOKEN_COOKIE_NAME}. csrfToken is required when refreshToken " + "is provided as a cookie." + ) + error_type_class = AccountError + error_type_field = "account_errors" + + @classmethod + def get_refresh_token_payload(cls, refresh_token): + try: + payload = get_payload(refresh_token) + except ValidationError as e: + raise ValidationError({"refreshToken": e}) + return payload + + @classmethod + def get_refresh_token(cls, info, data): + request = info.context + refresh_token = request.COOKIES.get(JWT_REFRESH_TOKEN_COOKIE_NAME, None) + refresh_token = data.get("refresh_token") or refresh_token + return refresh_token + + @classmethod + def clean_refresh_token(cls, refresh_token): + if not refresh_token: + raise ValidationError( + { + "refreshToken": ValidationError( + "Missing refreshToken", + code=AccountErrorCode.JWT_MISSING_TOKEN.value, + ) + } + ) + payload = cls.get_refresh_token_payload(refresh_token) + if payload["type"] != JWT_REFRESH_TYPE: + raise ValidationError( + { + "refreshToken": ValidationError( + "Incorrect refreshToken", + code=AccountErrorCode.JWT_INVALID_TOKEN.value, + ) + } + ) + return payload + + @classmethod + def clean_csrf_token(cls, csrf_token, payload): + is_valid = _compare_salted_tokens(csrf_token, payload["csrfToken"]) + if not is_valid: + raise ValidationError( + { + "csrfToken": ValidationError( + "Invalid csrf token", + code=AccountErrorCode.JWT_INVALID_CSRF_TOKEN.value, + ) + } + ) + + @classmethod + def get_user(cls, payload): + try: + user = get_user(payload) + except ValidationError as e: + raise ValidationError({"refreshToken": e}) + return user + + @classmethod + def perform_mutation(cls, root, info, **data): + refresh_token = cls.get_refresh_token(info, data) + payload = cls.clean_refresh_token(refresh_token) + + # None when we got refresh_token from cookie. + if not data.get("refresh_token"): + csrf_token = data.get("csrf_token") + cls.clean_csrf_token(csrf_token, payload) + + user = get_user(payload) + token = create_access_token(user) + return cls(errors=[], user=user, token=token) + + +class VerifyToken(BaseMutation): + """Mutation that confirms if token is valid and also returns user data.""" + + user = graphene.Field(User, description="User assigned to token.") + is_valid = graphene.Boolean( + required=True, + default_value=False, + description="Determine if token is valid or not.", + ) + payload = GenericScalar(description="JWT payload.") + + class Arguments: + token = graphene.String(required=True, description="JWT token to validate.") + + class Meta: + description = "Verify JWT token." + error_type_class = AccountError + error_type_field = "account_errors" + + @classmethod + def get_payload(cls, token): + try: + payload = get_payload(token) + except ValidationError as e: + raise ValidationError({"token": e}) + return payload + + @classmethod + def get_user(cls, payload): + try: + user = get_user(payload) + except ValidationError as e: + raise ValidationError({"token": e}) + return user + + @classmethod + def perform_mutation(cls, root, info, **data): + token = data["token"] + payload = cls.get_payload(token) + user = cls.get_user(payload) + return cls(errors=[], user=user, is_valid=True, payload=payload) + + +class DeactivateAllUserTokens(BaseMutation): + class Meta: + description = "Deactivate all JWT tokens of the currently authenticated user." + error_type_class = AccountError + error_type_field = "account_errors" + + @classmethod + def check_permissions(cls, context): + return context.user.is_authenticated + + @classmethod + def perform_mutation(cls, root, info, **data): + user = info.context.user + user.jwt_token_key = get_random_string() + user.save(update_fields=["jwt_token_key"]) + return cls() diff --git a/saleor/graphql/account/mutations/permission_group.py b/saleor/graphql/account/mutations/permission_group.py index 6aa93cde48b..f3cfb0a13a3 100644 --- a/saleor/graphql/account/mutations/permission_group.py +++ b/saleor/graphql/account/mutations/permission_group.py @@ -20,7 +20,6 @@ from ...core.mutations import ModelDeleteMutation, ModelMutation from ...core.types.common import PermissionGroupError from ...core.utils import get_duplicates_ids -from ..types import Group if TYPE_CHECKING: from ....account.models import User @@ -44,8 +43,6 @@ class PermissionGroupCreateInput(PermissionGroupInput): class PermissionGroupCreate(ModelMutation): - group = graphene.Field(Group, description="The newly created group.") - class Arguments: input = PermissionGroupCreateInput( description="Input fields to create permission group.", required=True @@ -181,8 +178,6 @@ class PermissionGroupUpdateInput(PermissionGroupInput): class PermissionGroupUpdate(PermissionGroupCreate): - group = graphene.Field(Group, description="Group which was edited.") - class Arguments: id = graphene.ID(description="ID of the group to update.", required=True) input = PermissionGroupUpdateInput( diff --git a/saleor/graphql/account/mutations/staff.py b/saleor/graphql/account/mutations/staff.py index a21ffcd1412..89037649387 100644 --- a/saleor/graphql/account/mutations/staff.py +++ b/saleor/graphql/account/mutations/staff.py @@ -4,8 +4,6 @@ import graphene from django.core.exceptions import ValidationError from django.db import transaction -from graphql_jwt.decorators import staff_member_required -from graphql_jwt.exceptions import PermissionDenied from ....account import events as account_events, models, utils from ....account.emails import send_set_password_email_with_url @@ -13,6 +11,7 @@ from ....account.thumbnails import create_user_avatar_thumbnails from ....account.utils import remove_staff_member from ....checkout import AddressType +from ....core.exceptions import PermissionDenied from ....core.permissions import AccountPermissions from ....core.utils.url import validate_storefront_url from ...account.enums import AddressTypeEnum @@ -21,6 +20,7 @@ from ...core.types import Upload from ...core.types.common import AccountError, StaffError from ...core.utils import get_duplicates_ids, validate_image_file +from ...decorators import staff_member_required from ...meta.deprecated.mutations import ClearMetaBaseMutation, UpdateMetaBaseMutation from ..utils import ( CustomerDeleteMixin, diff --git a/saleor/graphql/account/resolvers.py b/saleor/graphql/account/resolvers.py index 54a23b23c93..5a5ef5fa17b 100644 --- a/saleor/graphql/account/resolvers.py +++ b/saleor/graphql/account/resolvers.py @@ -3,10 +3,10 @@ import graphene from django.contrib.auth import models as auth_models -from graphql_jwt.exceptions import PermissionDenied from i18naddress import get_validation_rules from ...account import models +from ...core.exceptions import PermissionDenied from ...core.permissions import AccountPermissions from ...payment import gateway from ...payment.utils import fetch_customer_id diff --git a/saleor/graphql/account/schema.py b/saleor/graphql/account/schema.py index 9053a8b3386..3b54c381e5d 100644 --- a/saleor/graphql/account/schema.py +++ b/saleor/graphql/account/schema.py @@ -40,6 +40,12 @@ UserClearMeta, UserUpdateMeta, ) +from .mutations.jwt import ( + CreateToken, + DeactivateAllUserTokens, + RefreshToken, + VerifyToken, +) from .mutations.permission_group import ( PermissionGroupCreate, PermissionGroupDelete, @@ -219,6 +225,11 @@ def resolve_address(self, info, id): class AccountMutations(graphene.ObjectType): # Base mutations + token_create = CreateToken.Field() + token_refresh = RefreshToken.Field() + token_verify = VerifyToken.Field() + tokens_deactivate_all = DeactivateAllUserTokens.Field() + request_password_reset = RequestPasswordReset.Field() confirm_account = ConfirmAccount.Field() set_password = SetPassword.Field() diff --git a/saleor/graphql/account/tests/mutations/__init__.py b/saleor/graphql/account/tests/mutations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/saleor/graphql/account/tests/mutations/test_token_create_.py b/saleor/graphql/account/tests/mutations/test_token_create_.py new file mode 100644 index 00000000000..056a74db1e3 --- /dev/null +++ b/saleor/graphql/account/tests/mutations/test_token_create_.py @@ -0,0 +1,111 @@ +from datetime import datetime, timedelta + +import graphene +from django.middleware.csrf import _get_new_csrf_token +from freezegun import freeze_time +from jwt import decode + +from .....account.error_codes import AccountErrorCode +from .....core.jwt import ( + JWT_ACCESS_TYPE, + JWT_ALGORITHM, + JWT_REFRESH_TYPE, + create_refresh_token, +) +from ....tests.utils import get_graphql_content + +MUTATION_CREATE_TOKEN = """ + mutation tokenCreate($email: String!, $password: String!){ + tokenCreate(email: $email, password: $password) { + token + refreshToken + csrfToken + user { + email + } + errors { + field + message + } + accountErrors { + field + message + code + } + } + } +""" + + +@freeze_time("2020-03-18 12:00:00") +def test_create_token(api_client, customer_user, settings): + variables = {"email": customer_user.email, "password": customer_user._password} + response = api_client.post_graphql(MUTATION_CREATE_TOKEN, variables) + content = get_graphql_content(response) + + data = content["data"]["tokenCreate"] + + user_email = data["user"]["email"] + assert customer_user.email == user_email + assert content["data"]["tokenCreate"]["accountErrors"] == [] + + token = data["token"] + refreshToken = data["refreshToken"] + + payload = decode(token, settings.SECRET_KEY, algorithms=JWT_ALGORITHM) + assert payload["email"] == customer_user.email + assert payload["user_id"] == graphene.Node.to_global_id("User", customer_user.id) + assert datetime.fromtimestamp(payload["iat"]) == datetime.utcnow() + expected_expiration_datetime = datetime.utcnow() + settings.JWT_TTL_ACCESS + assert datetime.fromtimestamp(payload["exp"]) == expected_expiration_datetime + assert payload["type"] == JWT_ACCESS_TYPE + + payload = decode(refreshToken, settings.SECRET_KEY, algorithms=JWT_ALGORITHM) + assert payload["email"] == customer_user.email + assert datetime.fromtimestamp(payload["iat"]) == datetime.utcnow() + expected_expiration_datetime = datetime.utcnow() + settings.JWT_TTL_REFRESH + assert datetime.fromtimestamp(payload["exp"]) == expected_expiration_datetime + assert payload["type"] == JWT_REFRESH_TYPE + assert payload["token"] == customer_user.jwt_token_key + + +@freeze_time("2020-03-18 12:00:00") +def test_create_token_sets_cookie(api_client, customer_user, settings, monkeypatch): + csrf_token = _get_new_csrf_token() + monkeypatch.setattr( + "saleor.graphql.account.mutations.jwt._get_new_csrf_token", lambda: csrf_token + ) + variables = {"email": customer_user.email, "password": customer_user._password} + response = api_client.post_graphql(MUTATION_CREATE_TOKEN, variables) + + expected_refresh_token = create_refresh_token( + customer_user, {"csrfToken": csrf_token} + ) + refresh_token = response.cookies["refreshToken"] + assert refresh_token.value == expected_refresh_token + expected_expires = datetime.utcnow() + settings.JWT_TTL_REFRESH + expected_expires += timedelta(seconds=1) + expires = datetime.strptime(refresh_token["expires"], "%a, %d %b %Y %H:%M:%S %Z") + assert expires == expected_expires + assert refresh_token["httponly"] + assert refresh_token["secure"] + + +def test_create_token_invalid_password(api_client, customer_user): + variables = {"email": customer_user.email, "password": "wrongpassword"} + expected_error_code = AccountErrorCode.INVALID_CREDENTIALS.value.upper() + response = api_client.post_graphql(MUTATION_CREATE_TOKEN, variables) + content = get_graphql_content(response) + response_error = content["data"]["tokenCreate"]["accountErrors"][0] + assert response_error["code"] == expected_error_code + assert response_error["field"] == "email" + + +def test_create_token_invalid_email(api_client, customer_user): + variables = {"email": "wrongemail", "password": "wrongpassword"} + expected_error_code = AccountErrorCode.INVALID_CREDENTIALS.value.upper() + response = api_client.post_graphql(MUTATION_CREATE_TOKEN, variables) + content = get_graphql_content(response) + response_error = content["data"]["tokenCreate"]["accountErrors"][0] + assert response_error["code"] == expected_error_code + assert response_error["field"] == "email" diff --git a/saleor/graphql/account/tests/mutations/test_token_refresh.py b/saleor/graphql/account/tests/mutations/test_token_refresh.py new file mode 100644 index 00000000000..3eb7f225a23 --- /dev/null +++ b/saleor/graphql/account/tests/mutations/test_token_refresh.py @@ -0,0 +1,194 @@ +from datetime import datetime + +from django.middleware.csrf import _get_new_csrf_token +from freezegun import freeze_time +from jwt import decode + +from .....account.error_codes import AccountErrorCode +from .....core.jwt import ( + JWT_ACCESS_TYPE, + JWT_ALGORITHM, + JWT_REFRESH_TOKEN_COOKIE_NAME, + create_access_token, + create_refresh_token, +) +from ....tests.utils import get_graphql_content + +MUTATION_TOKEN_REFRESH = """ + mutation tokenRefresh($token: String, $csrf_token: String){ + tokenRefresh(refreshToken: $token, csrfToken: $csrf_token){ + token + accountErrors{ + code + } + } + } +""" + + +@freeze_time("2020-03-18 12:00:00") +def test_refresh_token_get_token_from_cookie(api_client, customer_user, settings): + csrf_token = _get_new_csrf_token() + refresh_token = create_refresh_token(customer_user, {"csrfToken": csrf_token}) + variables = {"token": None, "csrf_token": csrf_token} + api_client.cookies[JWT_REFRESH_TOKEN_COOKIE_NAME] = refresh_token + api_client.cookies[JWT_REFRESH_TOKEN_COOKIE_NAME]["httponly"] = True + response = api_client.post_graphql(MUTATION_TOKEN_REFRESH, variables) + content = get_graphql_content(response) + + data = content["data"]["tokenRefresh"] + errors = data["accountErrors"] + + assert not errors + token = data.get("token") + assert token + payload = decode(token, settings.SECRET_KEY, algorithms=JWT_ALGORITHM) + assert payload["email"] == customer_user.email + assert datetime.fromtimestamp(payload["iat"]) == datetime.utcnow() + assert ( + datetime.fromtimestamp(payload["exp"]) + == datetime.utcnow() + settings.JWT_TTL_ACCESS + ) + assert payload["type"] == JWT_ACCESS_TYPE + assert payload["token"] == customer_user.jwt_token_key + + +@freeze_time("2020-03-18 12:00:00") +def test_refresh_token_get_token_from_input(api_client, customer_user, settings): + csrf_token = _get_new_csrf_token() + refresh_token = create_refresh_token(customer_user, {"csrfToken": csrf_token}) + variables = {"token": refresh_token, "csrf_token": None} + response = api_client.post_graphql(MUTATION_TOKEN_REFRESH, variables) + content = get_graphql_content(response) + + data = content["data"]["tokenRefresh"] + errors = data["accountErrors"] + + assert not errors + token = data.get("token") + assert token + payload = decode(token, settings.SECRET_KEY, algorithms=JWT_ALGORITHM) + assert payload["email"] == customer_user.email + assert datetime.fromtimestamp(payload["iat"]) == datetime.utcnow() + assert ( + datetime.fromtimestamp(payload["exp"]) + == datetime.utcnow() + settings.JWT_TTL_ACCESS + ) + assert payload["type"] == JWT_ACCESS_TYPE + + +def test_refresh_token_get_token_missing_token(api_client, customer_user): + variables = {"token": None, "csrf_token": "token"} + response = api_client.post_graphql(MUTATION_TOKEN_REFRESH, variables) + content = get_graphql_content(response) + + data = content["data"]["tokenRefresh"] + errors = data["accountErrors"] + + token = data.get("token") + assert not token + + assert len(errors) == 1 + assert errors[0]["code"] == AccountErrorCode.JWT_MISSING_TOKEN.name + + +def test_access_token_used_as_a_refresh_token(api_client, customer_user): + csrf_token = _get_new_csrf_token() + access_token = create_access_token(customer_user, {"csrfToken": csrf_token}) + + variables = {"token": access_token, "csrf_token": csrf_token} + response = api_client.post_graphql(MUTATION_TOKEN_REFRESH, variables) + content = get_graphql_content(response) + + data = content["data"]["tokenRefresh"] + errors = data["accountErrors"] + + token = data.get("token") + assert not token + + assert len(errors) == 1 + assert errors[0]["code"] == AccountErrorCode.JWT_INVALID_TOKEN.name + + +def test_refresh_token_get_token_incorrect_csrf_token(api_client, customer_user): + csrf_token = _get_new_csrf_token() + refresh_token = create_refresh_token(customer_user, {"csrfToken": csrf_token}) + variables = {"token": None, "csrf_token": "csrf_token"} + api_client.cookies[JWT_REFRESH_TOKEN_COOKIE_NAME] = refresh_token + api_client.cookies[JWT_REFRESH_TOKEN_COOKIE_NAME]["httponly"] = True + response = api_client.post_graphql(MUTATION_TOKEN_REFRESH, variables) + content = get_graphql_content(response) + + data = content["data"]["tokenRefresh"] + errors = data["accountErrors"] + + token = data.get("token") + assert not token + + assert len(errors) == 1 + assert errors[0]["code"] == AccountErrorCode.JWT_INVALID_CSRF_TOKEN.name + + +def test_refresh_token_when_expired(api_client, customer_user): + with freeze_time("2018-05-31 12:00:01"): + csrf_token = _get_new_csrf_token() + refresh_token = create_refresh_token(customer_user, {"csrfToken": csrf_token}) + + variables = {"token": None, "csrf_token": csrf_token} + api_client.cookies[JWT_REFRESH_TOKEN_COOKIE_NAME] = refresh_token + api_client.cookies[JWT_REFRESH_TOKEN_COOKIE_NAME]["httponly"] = True + + response = api_client.post_graphql(MUTATION_TOKEN_REFRESH, variables) + + content = get_graphql_content(response) + + data = content["data"]["tokenRefresh"] + errors = data["accountErrors"] + + token = data.get("token") + assert not token + + assert len(errors) == 1 + assert errors[0]["code"] == AccountErrorCode.JWT_SIGNATURE_EXPIRED.name + + +def test_refresh_token_when_incorrect_token(api_client, customer_user): + csrf_token = _get_new_csrf_token() + refresh_token = create_refresh_token(customer_user, {"csrfToken": csrf_token}) + + variables = {"token": None, "csrf_token": csrf_token} + api_client.cookies[JWT_REFRESH_TOKEN_COOKIE_NAME] = refresh_token + "wrong-token" + api_client.cookies[JWT_REFRESH_TOKEN_COOKIE_NAME]["httponly"] = True + + response = api_client.post_graphql(MUTATION_TOKEN_REFRESH, variables) + + content = get_graphql_content(response) + + data = content["data"]["tokenRefresh"] + errors = data["accountErrors"] + + token = data.get("token") + assert not token + + assert len(errors) == 1 + assert errors[0]["code"] == AccountErrorCode.JWT_DECODE_ERROR.name + + +def test_refresh_token_when_user_deactivated_token(api_client, customer_user): + csrf_token = _get_new_csrf_token() + refresh_token = create_refresh_token(customer_user, {"csrfToken": csrf_token}) + customer_user.jwt_token_key = "new_key" + customer_user.save() + variables = {"token": None, "csrf_token": csrf_token} + api_client.cookies[JWT_REFRESH_TOKEN_COOKIE_NAME] = refresh_token + api_client.cookies[JWT_REFRESH_TOKEN_COOKIE_NAME]["httponly"] = True + + response = api_client.post_graphql(MUTATION_TOKEN_REFRESH, variables) + content = get_graphql_content(response) + + data = content["data"]["tokenRefresh"] + errors = data["accountErrors"] + + assert not data["token"] + assert len(errors) == 1 + assert errors[0]["code"] == AccountErrorCode.JWT_INVALID_TOKEN.name diff --git a/saleor/graphql/account/tests/mutations/test_token_verify.py b/saleor/graphql/account/tests/mutations/test_token_verify.py new file mode 100644 index 00000000000..e826f8c164c --- /dev/null +++ b/saleor/graphql/account/tests/mutations/test_token_verify.py @@ -0,0 +1,53 @@ +from .....account.error_codes import AccountErrorCode +from .....core.jwt import create_access_token +from ....tests.utils import get_graphql_content + +MUTATION_TOKEN_VERIFY = """ + mutation tokenVerify($token: String!){ + tokenVerify(token: $token){ + isValid + user{ + email + } + accountErrors{ + code + } + } + } +""" + + +def test_verify_token(api_client, customer_user): + variables = {"token": create_access_token(customer_user)} + response = api_client.post_graphql(MUTATION_TOKEN_VERIFY, variables) + content = get_graphql_content(response) + data = content["data"]["tokenVerify"] + assert data["isValid"] is True + user_email = content["data"]["tokenVerify"]["user"]["email"] + assert customer_user.email == user_email + + +def test_verify_token_incorrect_token(api_client): + variables = {"token": "incorrect_token"} + response = api_client.post_graphql(MUTATION_TOKEN_VERIFY, variables) + content = get_graphql_content(response) + data = content["data"]["tokenVerify"] + errors = data["accountErrors"] + assert len(errors) == 1 + assert errors[0]["code"] == AccountErrorCode.JWT_DECODE_ERROR.name + assert data["isValid"] is False + assert not data["user"] + + +def test_verify_token_invalidated_by_user(api_client, customer_user): + variables = {"token": create_access_token(customer_user)} + customer_user.jwt_token_key = "new token" + customer_user.save() + response = api_client.post_graphql(MUTATION_TOKEN_VERIFY, variables) + content = get_graphql_content(response) + data = content["data"]["tokenVerify"] + errors = data["accountErrors"] + + assert data["isValid"] is False + assert len(errors) == 1 + assert errors[0]["code"] == AccountErrorCode.JWT_INVALID_TOKEN.name diff --git a/saleor/graphql/account/tests/mutations/test_tokens_deactivate_all.py b/saleor/graphql/account/tests/mutations/test_tokens_deactivate_all.py new file mode 100644 index 00000000000..92acfd83f10 --- /dev/null +++ b/saleor/graphql/account/tests/mutations/test_tokens_deactivate_all.py @@ -0,0 +1,82 @@ +from django.middleware.csrf import _get_new_csrf_token +from freezegun import freeze_time + +from .....account.error_codes import AccountErrorCode +from .....core.jwt import create_access_token, create_refresh_token, jwt_decode +from ....tests.utils import get_graphql_content +from .test_token_refresh import MUTATION_TOKEN_REFRESH + +MUTATION_DEACTIVATE_ALL_USER_TOKENS = """ +mutation{ + tokensDeactivateAll{ + accountErrors{ + field + message + code + } + } +} + +""" + + +@freeze_time("2020-03-18 12:00:00") +def test_deactivate_all_user_tokens(customer_user, user_api_client): + token = create_access_token(customer_user) + jwt_key = customer_user.jwt_token_key + + csrf_token = _get_new_csrf_token() + refresh_token = create_refresh_token(customer_user, {"csrfToken": csrf_token}) + + user_api_client.token = token + user_api_client.post_graphql(MUTATION_DEACTIVATE_ALL_USER_TOKENS) + customer_user.refresh_from_db() + + new_token = create_access_token(customer_user) + new_refresh_token = create_refresh_token(customer_user, {"csrfToken": csrf_token}) + # the mutation changes the parameter of jwt token, which confirms if jwt token is + # valid or not + assert jwt_decode(token)["token"] != jwt_decode(new_token)["token"] + assert jwt_decode(refresh_token)["token"] != jwt_decode(new_refresh_token)["token"] + assert jwt_key != customer_user.jwt_token_key + + +def test_deactivate_all_user_tokens_access_token(user_api_client, customer_user): + token = create_access_token(customer_user) + user_api_client.token = token + response = user_api_client.post_graphql(MUTATION_DEACTIVATE_ALL_USER_TOKENS) + content = get_graphql_content(response) + + errors = content["data"]["tokensDeactivateAll"]["accountErrors"] + assert not errors + + query = "{me { id }}" + response = user_api_client.post_graphql(query) + content = get_graphql_content(response) + assert content["data"]["me"] is None + + +def test_deactivate_all_user_token_refresh_token( + api_client, user_api_client, customer_user +): + user_api_client.token = create_access_token(customer_user) + create_refresh_token(customer_user) + csrf_token = _get_new_csrf_token() + refresh_token = create_refresh_token(customer_user, {"csrfToken": csrf_token}) + + response = user_api_client.post_graphql(MUTATION_DEACTIVATE_ALL_USER_TOKENS) + content = get_graphql_content(response) + errors = content["data"]["tokensDeactivateAll"]["accountErrors"] + assert not errors + + variables = {"token": refresh_token, "csrf_token": csrf_token} + response = api_client.post_graphql(MUTATION_TOKEN_REFRESH, variables) + content = get_graphql_content(response) + + data = content["data"]["tokenRefresh"] + errors = data["accountErrors"] + + assert data["token"] is None + assert len(errors) == 1 + + assert errors[0]["code"] == AccountErrorCode.JWT_INVALID_TOKEN.name diff --git a/saleor/graphql/account/tests/test_account.py b/saleor/graphql/account/tests/test_account.py index d5689c0125f..78cf2045181 100644 --- a/saleor/graphql/account/tests/test_account.py +++ b/saleor/graphql/account/tests/test_account.py @@ -1,10 +1,10 @@ import re import uuid from collections import defaultdict +from datetime import timedelta from unittest.mock import ANY, MagicMock, Mock, patch import graphene -import jwt import pytest from django.contrib.auth.models import Group from django.contrib.auth.tokens import default_token_generator @@ -12,15 +12,14 @@ from django.core.files import File from django.core.validators import URLValidator from django.test import override_settings -from django.utils import timezone from freezegun import freeze_time from prices import Money from ....account import events as account_events from ....account.error_codes import AccountErrorCode from ....account.models import Address, User -from ....account.utils import create_jwt_token from ....checkout import AddressType +from ....core.jwt import create_token from ....core.permissions import AccountPermissions, OrderPermissions from ....order.models import FulfillmentStatus, Order from ....product.tests.utils import create_image @@ -73,80 +72,6 @@ def query_staff_users_with_filter(): return query -def test_create_token_mutation(api_client, staff_user, settings): - query = """ - mutation TokenCreate($email: String!, $password: String!) { - tokenCreate(email: $email, password: $password) { - token - errors { - field - message - } - } - } - """ - variables = {"email": staff_user.email, "password": "password"} - time = timezone.now() - with freeze_time(time): - response = api_client.post_graphql(query, variables) - content = get_graphql_content(response) - token_data = content["data"]["tokenCreate"] - token = jwt.decode(token_data["token"], settings.SECRET_KEY) - staff_user.refresh_from_db() - assert staff_user.last_login == time - assert token["email"] == staff_user.email - assert token["user_id"] == graphene.Node.to_global_id("User", staff_user.id) - - assert token_data["errors"] == [] - - incorrect_variables = {"email": staff_user.email, "password": "incorrect"} - response = api_client.post_graphql(query, incorrect_variables) - content = get_graphql_content(response) - token_data = content["data"]["tokenCreate"] - errors = token_data["errors"] - assert errors - assert not errors[0]["field"] - assert not token_data["token"] - - -def test_token_create_user_data(permission_manage_orders, staff_api_client, staff_user): - query = """ - mutation TokenCreate($email: String!, $password: String!) { - tokenCreate(email: $email, password: $password) { - user { - id - email - permissions { - code - name - } - userPermissions { - code - name - } - } - } - } - """ - - permission = permission_manage_orders - staff_user.user_permissions.add(permission) - name = permission.name - user_id = graphene.Node.to_global_id("User", staff_user.id) - - variables = {"email": staff_user.email, "password": "password"} - response = staff_api_client.post_graphql(query, variables) - content = get_graphql_content(response) - token_data = content["data"]["tokenCreate"] - assert token_data["user"]["id"] == user_id - assert token_data["user"]["email"] == staff_user.email - assert token_data["user"]["userPermissions"][0]["name"] == name - assert token_data["user"]["userPermissions"][0]["code"] == "MANAGE_ORDERS" - # deprecated, to remove in #5389 - assert token_data["user"]["permissions"][0]["name"] == name - assert token_data["user"]["permissions"][0]["code"] == "MANAGE_ORDERS" - - FULL_USER_QUERY = """ query User($id: ID!) { user(id: $id) { @@ -2478,6 +2403,7 @@ def test_staff_update_errors(staff_user, customer_user, admin_user): id } token + refreshToken } } """ @@ -4187,12 +4113,13 @@ def test_request_email_change_with_invalid_password(user_api_client, customer_us def test_email_update(user_api_client, customer_user): new_email = "new_email@example.com" - token_kwargs = { + payload = { "old_email": customer_user.email, "new_email": new_email, "user_pk": customer_user.pk, } - token = create_jwt_token(token_kwargs) + + token = create_token(payload, timedelta(hours=1)) variables = {"token": token} response = user_api_client.post_graphql(EMAIL_UPDATE_QUERY, variables) @@ -4202,12 +4129,12 @@ def test_email_update(user_api_client, customer_user): def test_email_update_to_existing_email(user_api_client, customer_user, staff_user): - token_kwargs = { + payload = { "old_email": customer_user.email, "new_email": staff_user.email, "user_pk": customer_user.pk, } - token = create_jwt_token(token_kwargs) + token = create_token(payload, timedelta(hours=1)) variables = {"token": token} response = user_api_client.post_graphql(EMAIL_UPDATE_QUERY, variables) diff --git a/saleor/graphql/account/types.py b/saleor/graphql/account/types.py index 5785a74f113..11187b51227 100644 --- a/saleor/graphql/account/types.py +++ b/saleor/graphql/account/types.py @@ -2,10 +2,10 @@ from django.contrib.auth import get_user_model, models as auth_models from graphene import relay from graphene_federation import key -from graphql_jwt.exceptions import PermissionDenied from ...account import models from ...checkout.utils import get_user_checkout +from ...core.exceptions import PermissionDenied from ...core.permissions import AccountPermissions, OrderPermissions from ...order import models as order_models from ..checkout.types import Checkout diff --git a/saleor/graphql/api.py b/saleor/graphql/api.py index 1923083033e..c389eaaf63e 100644 --- a/saleor/graphql/api.py +++ b/saleor/graphql/api.py @@ -3,7 +3,7 @@ from .account.schema import AccountMutations, AccountQueries from .app.schema import AppMutations, AppQueries from .checkout.schema import CheckoutMutations, CheckoutQueries -from .core.schema import CoreMutations, CoreQueries +from .core.schema import CoreQueries from .csv.schema import CsvMutations, CsvQueries from .discount.schema import DiscountMutations, DiscountQueries from .giftcard.schema import GiftCardMutations, GiftCardQueries @@ -49,7 +49,6 @@ class Mutation( AccountMutations, AppMutations, CheckoutMutations, - CoreMutations, CsvMutations, DiscountMutations, PluginsMutations, diff --git a/saleor/graphql/checkout/mutations.py b/saleor/graphql/checkout/mutations.py index 61f9a1e39d4..cbcf16975a6 100644 --- a/saleor/graphql/checkout/mutations.py +++ b/saleor/graphql/checkout/mutations.py @@ -5,7 +5,6 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import transaction from django.db.models import Prefetch -from graphql_jwt.exceptions import PermissionDenied from ...account.error_codes import AccountErrorCode from ...checkout import models @@ -24,7 +23,7 @@ remove_promo_code_from_checkout, ) from ...core import analytics -from ...core.exceptions import InsufficientStock, ProductNotPublished +from ...core.exceptions import InsufficientStock, PermissionDenied, ProductNotPublished from ...core.permissions import OrderPermissions from ...core.taxes import TaxError from ...core.utils.url import validate_storefront_url diff --git a/saleor/graphql/checkout/types.py b/saleor/graphql/checkout/types.py index a0a224250df..7a6358028c5 100644 --- a/saleor/graphql/checkout/types.py +++ b/saleor/graphql/checkout/types.py @@ -1,9 +1,9 @@ import graphene -from graphql_jwt.exceptions import PermissionDenied from promise import Promise from ...checkout import calculations, models from ...checkout.utils import get_valid_shipping_methods_for_checkout +from ...core.exceptions import PermissionDenied from ...core.permissions import AccountPermissions, CheckoutPermissions from ...core.taxes import display_gross_prices, zero_taxed_money from ...plugins.manager import get_plugins_manager diff --git a/saleor/graphql/core/mutations.py b/saleor/graphql/core/mutations.py index 799feb6ed8b..17b248856d9 100644 --- a/saleor/graphql/core/mutations.py +++ b/saleor/graphql/core/mutations.py @@ -2,28 +2,21 @@ from typing import Tuple, Union import graphene -from django.contrib.auth import get_user_model from django.core.exceptions import ( NON_FIELD_ERRORS, ImproperlyConfigured, ValidationError, ) from django.db.models.fields.files import FileField -from django.utils import timezone from graphene import ObjectType from graphene.types.mutation import MutationOptions from graphene_django.registry import get_global_registry from graphql.error import GraphQLError -from graphql_jwt import ObtainJSONWebToken, Refresh, Verify -from graphql_jwt.exceptions import JSONWebTokenError, PermissionDenied -from ...account import models -from ...account.error_codes import AccountErrorCode +from ...core.exceptions import PermissionDenied from ...core.permissions import AccountPermissions -from ..account.types import User from ..utils import get_nodes from .types import Error, Upload -from .types.common import AccountError from .utils import from_global_id_strict_type, snake_to_camel_case from .utils.error_codes import get_error_code_from_error @@ -36,17 +29,6 @@ def get_model_name(model): return model_name[:1].lower() + model_name[1:] -def get_output_fields(model, return_field_name): - """Return mutation output field for model instance.""" - model_type = registry.get_type_for_model(model) - if not model_type: - raise ImproperlyConfigured( - "Unable to find type for model %s in graphene registry" % model.__name__ - ) - fields = {return_field_name: graphene.Field(model_type)} - return fields - - def get_error_fields(error_type_class, error_type_field): return { error_type_field: graphene.Field( @@ -357,12 +339,19 @@ def __init_subclass_with_meta__( return_field_name = get_model_name(model) if arguments is None: arguments = {} - fields = get_output_fields(model, return_field_name) _meta.model = model _meta.return_field_name = return_field_name _meta.exclude = exclude super().__init_subclass_with_meta__(_meta=_meta, **options) + + model_type = cls.get_type_for_model() + if not model_type: + raise ImproperlyConfigured( + "Unable to find type for model %s in graphene registry" % model.__name__ + ) + fields = {return_field_name: graphene.Field(model_type)} + cls._update_mutation_arguments_and_fields(arguments=arguments, fields=fields) @classmethod @@ -604,95 +593,3 @@ class Meta: @classmethod def bulk_action(cls, queryset): queryset.delete() - - -class CreateToken(ObtainJSONWebToken): - """Mutation that authenticates a user and returns token and user data. - - It overrides the default graphql_jwt.ObtainJSONWebToken to wrap potential - authentication errors in our Error type, which is consistent to how the rest of - the mutation works. - """ - - errors = graphene.List( - graphene.NonNull(Error), - required=True, - deprecation_reason=( - "Use typed errors with error codes. This field will be removed after " - "2020-07-31." - ), - ) - account_errors = graphene.List( - graphene.NonNull(AccountError), - description="List of errors that occurred executing the mutation.", - required=True, - ) - user = graphene.Field(User, description="A user instance.") - - @classmethod - def mutate(cls, root, info, **kwargs): - try: - result = super().mutate(root, info, **kwargs) - except JSONWebTokenError as e: - errors = [Error(message=str(e))] - account_errors = [ - AccountError( - field="email", - message="Please, enter valid credentials", - code=AccountErrorCode.INVALID_CREDENTIALS, - ) - ] - return CreateToken(errors=errors, account_errors=account_errors) - except ValidationError as e: - errors = validation_error_to_error_type(e) - return cls.handle_typed_errors(errors) - else: - user = result.user - user.last_login = timezone.now() - user.save(update_fields=["last_login"]) - return result - - @classmethod - def handle_typed_errors(cls, errors: list): - account_errors = [ - AccountError(field=e.field, message=e.message, code=code) - for e, code, _params in errors - ] - return cls(errors=[e[0] for e in errors], account_errors=account_errors) - - @classmethod - def resolve(cls, root, info, **kwargs): - return cls(user=info.context.user, errors=[], account_errors=[]) - - -class RefreshToken(Refresh): - """Mutation that refresh user token. - - It overrides the default graphql_jwt.Refresh to update user's last_login field. - """ - - @classmethod - def mutate(cls, root, info, **kwargs): - result = super().mutate(root, info, **kwargs) - user = graphene.Node.get_node_from_global_id(info, result.payload["user_id"]) - user.last_login = timezone.now() - user.save(update_fields=["last_login"]) - return result - - -class VerifyToken(Verify): - """Mutation that confirms if token is valid and also returns user data.""" - - user = graphene.Field(User) - - def resolve_user(self, _info, **_kwargs): - username_field = get_user_model().USERNAME_FIELD - kwargs = {username_field: self.payload.get(username_field)} - return models.User.objects.get(**kwargs) - - @classmethod - def mutate(cls, root, info, token, **kwargs): - try: - return super().mutate(root, info, token, **kwargs) - except JSONWebTokenError: - return None diff --git a/saleor/graphql/core/schema.py b/saleor/graphql/core/schema.py index 3d41c6388f0..509381b2852 100644 --- a/saleor/graphql/core/schema.py +++ b/saleor/graphql/core/schema.py @@ -1,15 +1,8 @@ import graphene -from .mutations import CreateToken, RefreshToken, VerifyToken from .types.common import TaxType -class CoreMutations(graphene.ObjectType): - token_create = CreateToken.Field() - token_refresh = RefreshToken.Field() - token_verify = VerifyToken.Field() - - class CoreQueries(graphene.ObjectType): tax_types = graphene.List( TaxType, description="List of all tax rates available from tax gateway." diff --git a/saleor/graphql/core/tests/test_core.py b/saleor/graphql/core/tests/test_core.py index 6b552c0860d..39d7bab5dc4 100644 --- a/saleor/graphql/core/tests/test_core.py +++ b/saleor/graphql/core/tests/test_core.py @@ -7,9 +7,7 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError from django.utils import timezone from graphene import InputField -from graphql_jwt.shortcuts import get_token -from ....account.error_codes import AccountErrorCode from ....product.models import Category, Product from ...product import types as product_types from ...tests.utils import _get_graphql_content_from_response, get_graphql_content @@ -288,83 +286,6 @@ def _run_test(): assert exc.value.args[0] == "Permissions should be a tuple or a string in Meta" -MUTATION_CREATE_TOKEN = """ - mutation tokenCreate($email: String!, $password: String!){ - tokenCreate(email: $email, password: $password) { - token - user { - email - } - errors { - field - message - } - accountErrors { - field - message - code - } - } - } -""" - - -def test_create_token(api_client, customer_user): - variables = {"email": customer_user.email, "password": customer_user._password} - response = api_client.post_graphql(MUTATION_CREATE_TOKEN, variables) - content = get_graphql_content(response) - user_email = content["data"]["tokenCreate"]["user"]["email"] - assert customer_user.email == user_email - assert content["data"]["tokenCreate"]["token"] - assert content["data"]["tokenCreate"]["accountErrors"] == [] - - -def test_create_token_invalid_password(api_client, customer_user): - variables = {"email": customer_user.email, "password": "wrongpassword"} - expected_error_code = AccountErrorCode.INVALID_CREDENTIALS.value.upper() - response = api_client.post_graphql(MUTATION_CREATE_TOKEN, variables) - content = get_graphql_content(response) - response_error = content["data"]["tokenCreate"]["accountErrors"][0] - assert response_error["code"] == expected_error_code - assert response_error["field"] == "email" - - -def test_create_token_invalid_email(api_client, customer_user): - variables = {"email": "wrongemail", "password": "wrongpassword"} - expected_error_code = AccountErrorCode.INVALID_CREDENTIALS.value.upper() - response = api_client.post_graphql(MUTATION_CREATE_TOKEN, variables) - content = get_graphql_content(response) - response_error = content["data"]["tokenCreate"]["accountErrors"][0] - assert response_error["code"] == expected_error_code - assert response_error["field"] == "email" - - -MUTATION_TOKEN_VERIFY = """ - mutation tokenVerify($token: String!){ - tokenVerify(token: $token){ - user{ - email - } - } - } -""" - - -def test_verify_token(api_client, customer_user): - variables = {"token": get_token(customer_user)} - response = api_client.post_graphql(MUTATION_TOKEN_VERIFY, variables) - content = get_graphql_content(response) - user_email = content["data"]["tokenVerify"]["user"]["email"] - assert customer_user.email == user_email - - -def test_verify_token_incorrect_token(api_client): - variables = {"token": "incorrect_token"} - response = api_client.post_graphql(MUTATION_TOKEN_VERIFY, variables) - content = get_graphql_content(response) - assert not content["data"]["tokenVerify"] - - @pytest.mark.parametrize( "cleaned_input", [ diff --git a/saleor/graphql/core/utils/__init__.py b/saleor/graphql/core/utils/__init__.py index 823ca6292e8..201edcf4822 100644 --- a/saleor/graphql/core/utils/__init__.py +++ b/saleor/graphql/core/utils/__init__.py @@ -108,3 +108,14 @@ def get_duplicates_ids(first_list, second_list): def get_duplicated_values(values): """Return set of duplicated values.""" return {value for value in values if values.count(value) > 1} + + +def validate_required_string_field(cleaned_input, field_name: str): + """Strip and validate field value.""" + field_value = cleaned_input.get(field_name) + field_value = field_value.strip() if field_value else "" + if field_value: + cleaned_input[field_name] = field_value + else: + raise ValidationError(f"{field_name.capitalize()} is required.") + return cleaned_input diff --git a/saleor/graphql/csv/types.py b/saleor/graphql/csv/types.py index 54934564902..3b9a524ac95 100644 --- a/saleor/graphql/csv/types.py +++ b/saleor/graphql/csv/types.py @@ -1,6 +1,6 @@ import graphene -from graphql_jwt.exceptions import PermissionDenied +from ...core.exceptions import PermissionDenied from ...core.permissions import AccountPermissions, AppPermission from ...csv import models from ..account.types import User diff --git a/saleor/graphql/decorators.py b/saleor/graphql/decorators.py index 36883a532cd..759594a7ec5 100644 --- a/saleor/graphql/decorators.py +++ b/saleor/graphql/decorators.py @@ -2,12 +2,23 @@ from functools import wraps from typing import Iterable, Union -from graphql_jwt import exceptions -from graphql_jwt.decorators import context +from graphql.execution.base import ResolveInfo +from ..core.exceptions import PermissionDenied from ..core.permissions import AccountPermissions +def context(f): + def decorator(func): + def wrapper(*args, **kwargs): + info = next(arg for arg in args if isinstance(arg, ResolveInfo)) + return func(info.context, *args, **kwargs) + + return wrapper + + return decorator + + def account_passes_test(test_func): """Determine if user/app has permission to access to content.""" @@ -17,7 +28,7 @@ def decorator(f): def wrapper(context, *args, **kwargs): if test_func(context): return f(*args, **kwargs) - raise exceptions.PermissionDenied() + raise PermissionDenied() return wrapper @@ -56,3 +67,8 @@ def check_perms(context): return False return account_passes_test(check_perms) + + +staff_member_required = account_passes_test( + lambda context: context.user.is_active and context.user.is_staff +) diff --git a/saleor/graphql/giftcard/types.py b/saleor/graphql/giftcard/types.py index fef0c14c2b1..2ff2112dcd4 100644 --- a/saleor/graphql/giftcard/types.py +++ b/saleor/graphql/giftcard/types.py @@ -1,6 +1,6 @@ import graphene -from graphql_jwt.exceptions import PermissionDenied +from ...core.exceptions import PermissionDenied from ...core.permissions import AccountPermissions, GiftcardPermissions from ...giftcard import models from ..core.connection import CountableDjangoObjectType diff --git a/saleor/graphql/meta/deprecated/mutations.py b/saleor/graphql/meta/deprecated/mutations.py index dceb2844325..e2a96235a2f 100644 --- a/saleor/graphql/meta/deprecated/mutations.py +++ b/saleor/graphql/meta/deprecated/mutations.py @@ -3,12 +3,23 @@ from django.core.exceptions import ImproperlyConfigured from graphene_django.registry import get_global_registry -from ...core.mutations import BaseMutation, get_model_name, get_output_fields +from ...core.mutations import BaseMutation, get_model_name from .types import MetaInput, MetaPath registry = get_global_registry() +def get_output_fields(model, return_field_name): + """Return mutation output field for model instance.""" + model_type = registry.get_type_for_model(model) + if not model_type: + raise ImproperlyConfigured( + "Unable to find type for model %s in graphene registry" % model.__name__ + ) + fields = {return_field_name: graphene.Field(model_type)} + return fields + + class MetaUpdateOptions(graphene.types.mutation.MutationOptions): model = None return_field_name = None diff --git a/saleor/graphql/meta/mutations.py b/saleor/graphql/meta/mutations.py index dabdf45f31d..93f5815dcd3 100644 --- a/saleor/graphql/meta/mutations.py +++ b/saleor/graphql/meta/mutations.py @@ -1,9 +1,9 @@ import graphene from django.core.exceptions import ValidationError -from graphql_jwt.exceptions import PermissionDenied from ...core import models from ...core.error_codes import MetadataErrorCode +from ...core.exceptions import PermissionDenied from ..core.mutations import BaseMutation from ..core.types.common import MetadataError from .permissions import PRIVATE_META_PERMISSION_MAP, PUBLIC_META_PERMISSION_MAP diff --git a/saleor/graphql/meta/permissions.py b/saleor/graphql/meta/permissions.py index 8e5ba0a6e7b..9fafef86244 100644 --- a/saleor/graphql/meta/permissions.py +++ b/saleor/graphql/meta/permissions.py @@ -1,8 +1,7 @@ from typing import Any, List -from graphql_jwt.exceptions import PermissionDenied - from ...account import models as account_models +from ...core.exceptions import PermissionDenied from ...core.permissions import ( AccountPermissions, AppPermission, diff --git a/saleor/graphql/meta/resolvers.py b/saleor/graphql/meta/resolvers.py index 384cbcbe261..b71016533c1 100644 --- a/saleor/graphql/meta/resolvers.py +++ b/saleor/graphql/meta/resolvers.py @@ -1,10 +1,9 @@ from operator import itemgetter -from graphql_jwt.exceptions import PermissionDenied - from ...account import models as account_models from ...app import models as app_models from ...checkout import models as checkout_models +from ...core.exceptions import PermissionDenied from ...core.models import ModelWithMetadata from ...order import models as order_models from ...product import models as product_models @@ -34,7 +33,7 @@ def resolve_object_with_metadata_type(instance: ModelWithMetadata): app_models.App: app_types.App, account_models.User: account_types.User, } - return MODEL_TO_TYPE_MAP.get(type(instance), None) + return MODEL_TO_TYPE_MAP.get(instance.__class__, None) def resolve_metadata(metadata: dict): diff --git a/saleor/graphql/middleware.py b/saleor/graphql/middleware.py index 65f78d047cb..55a4c4954c0 100644 --- a/saleor/graphql/middleware.py +++ b/saleor/graphql/middleware.py @@ -3,10 +3,10 @@ import opentracing import opentracing.tags from django.conf import settings +from django.contrib.auth import authenticate from django.contrib.auth.models import AnonymousUser from django.utils.functional import SimpleLazyObject from graphql import ResolveInfo -from graphql_jwt.middleware import JSONWebTokenMiddleware from ..app.models import App from ..core.exceptions import ReadOnlyException @@ -14,13 +14,21 @@ from .views import API_PATH, GraphQLView -class JWTMiddleware(JSONWebTokenMiddleware): +def get_user(request): + if not hasattr(request, "_cached_user"): + request._cached_user = authenticate(request=request) + return request._cached_user + + +class JWTMiddleware: def resolve(self, next, root, info, **kwargs): request = info.context - if not hasattr(request, "user"): - request.user = AnonymousUser() - return super().resolve(next, root, info, **kwargs) + def user(): + return get_user(request) or AnonymousUser() + + request.user = SimpleLazyObject(lambda: user()) + return next(root, info, **kwargs) class OpentracingGrapheneMiddleware: diff --git a/saleor/graphql/order/mutations/orders.py b/saleor/graphql/order/mutations/orders.py index 2c013bfb67b..61713a0d04d 100644 --- a/saleor/graphql/order/mutations/orders.py +++ b/saleor/graphql/order/mutations/orders.py @@ -8,6 +8,7 @@ from ....order.actions import ( cancel_order, clean_mark_order_as_paid, + handle_fully_paid_order, mark_order_as_paid, order_captured, order_refunded, @@ -21,6 +22,7 @@ from ...core.mutations import BaseMutation from ...core.scalars import UUID, Decimal from ...core.types.common import OrderError +from ...core.utils import validate_required_string_field from ...meta.deprecated.mutations import ClearMetaBaseMutation, UpdateMetaBaseMutation from ...meta.deprecated.types import MetaInput, MetaPath from ...order.mutations.draft_orders import DraftOrderUpdate @@ -282,8 +284,9 @@ class Meta: @classmethod def clean_input(cls, _info, _instance, data): - message = data["input"]["message"].strip() - if not message: + try: + cleaned_input = validate_required_string_field(data["input"], "message") + except ValidationError: raise ValidationError( { "message": ValidationError( @@ -291,17 +294,14 @@ def clean_input(cls, _info, _instance, data): ) } ) - data["input"]["message"] = message - return data + return cleaned_input @classmethod def perform_mutation(cls, _root, info, **data): order = cls.get_node_or_error(info, data.get("id"), only_type=Order) cleaned_input = cls.clean_input(info, order, data) event = events.order_note_added_event( - order=order, - user=info.context.user, - message=cleaned_input["input"]["message"], + order=order, user=info.context.user, message=cleaned_input["message"], ) return OrderAddNote(order=order, event=event) @@ -391,8 +391,9 @@ def perform_mutation(cls, _root, info, amount, **data): try_payment_action( order, info.context.user, payment, gateway.capture, payment, amount ) - order_captured(order, info.context.user, amount, payment) + if order.is_fully_paid(): + handle_fully_paid_order(order) return OrderCapture(order=order) diff --git a/saleor/graphql/order/tests/test_order.py b/saleor/graphql/order/tests/test_order.py index 6f1cef38d12..a0b26765af1 100644 --- a/saleor/graphql/order/tests/test_order.py +++ b/saleor/graphql/order/tests/test_order.py @@ -1576,16 +1576,8 @@ def test_order_capture( assert data["isPaid"] assert data["totalCaptured"]["amount"] == float(amount) - event_order_paid = order.events.first() - assert event_order_paid.type == order_events.OrderEvents.ORDER_FULLY_PAID - assert event_order_paid.user is None + event_captured, event_order_fully_paid, event_email_sent = order.events.all() - event_email_sent, event_captured = list(order.events.all())[-2:] - assert event_email_sent.user is None - assert event_email_sent.parameters == { - "email": order.user_email, - "email_type": order_events.OrderEventsEmails.PAYMENT, - } assert event_captured.type == order_events.OrderEvents.PAYMENT_CAPTURED assert event_captured.user == staff_user assert event_captured.parameters == { @@ -1594,6 +1586,15 @@ def test_order_capture( "payment_id": "", } + assert event_order_fully_paid.type == order_events.OrderEvents.ORDER_FULLY_PAID + assert event_order_fully_paid.user is None + + assert event_email_sent.user is None + assert event_email_sent.parameters == { + "email": order.user_email, + "email_type": order_events.OrderEventsEmails.PAYMENT, + } + def test_paid_order_mark_as_paid( staff_api_client, permission_manage_orders, payment_txn_preauth diff --git a/saleor/graphql/order/types.py b/saleor/graphql/order/types.py index dd39ee4cdc3..8dbd0d363c5 100644 --- a/saleor/graphql/order/types.py +++ b/saleor/graphql/order/types.py @@ -1,8 +1,8 @@ import graphene from django.core.exceptions import ValidationError from graphene import relay -from graphql_jwt.exceptions import PermissionDenied +from ...core.exceptions import PermissionDenied from ...core.permissions import AccountPermissions, OrderPermissions from ...core.taxes import display_gross_prices from ...order import OrderStatus, models diff --git a/saleor/graphql/product/mutations/products.py b/saleor/graphql/product/mutations/products.py index 28c3f0817ce..8f12fb5e8b2 100644 --- a/saleor/graphql/product/mutations/products.py +++ b/saleor/graphql/product/mutations/products.py @@ -7,9 +7,9 @@ from django.db.models import Q, QuerySet from django.template.defaultfilters import slugify from graphene.types import InputObjectType -from graphql_jwt.exceptions import PermissionDenied from graphql_relay import from_global_id +from ....core.exceptions import PermissionDenied from ....core.permissions import ProductPermissions from ....product import models from ....product.error_codes import ProductErrorCode diff --git a/saleor/graphql/product/tests/test_attributes.py b/saleor/graphql/product/tests/test_attributes.py index 71110a49681..534052ef162 100644 --- a/saleor/graphql/product/tests/test_attributes.py +++ b/saleor/graphql/product/tests/test_attributes.py @@ -199,10 +199,10 @@ def test_attributes_query_hidden_attribute_as_staff_user( @pytest.mark.parametrize("is_staff", (False, True)) def test_resolve_attributes_with_hidden( user_api_client, + staff_api_client, product, color_attribute, size_attribute, - staff_user, is_staff, permission_manage_products, ): @@ -221,10 +221,10 @@ def test_resolve_attributes_with_hidden( expected_variant_attribute_count = variant.attributes.count() - 1 if is_staff: - api_client.user = staff_user + api_client = staff_api_client + api_client.user.user_permissions.add(permission_manage_products) expected_product_attribute_count += 1 expected_variant_attribute_count += 1 - staff_user.user_permissions.add(permission_manage_products) # Hide one product and variant attribute from the storefront for attribute in (product_attribute, variant_attribute): diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql index bdef99a43f5..bdb44d2c5e0 100644 --- a/saleor/graphql/schema.graphql +++ b/saleor/graphql/schema.graphql @@ -62,6 +62,11 @@ enum AccountErrorCode { PASSWORD_TOO_SIMILAR REQUIRED UNIQUE + JWT_SIGNATURE_EXPIRED + JWT_INVALID_TOKEN + JWT_DECODE_ERROR + JWT_MISSING_TOKEN + JWT_INVALID_CSRF_TOKEN } input AccountInput { @@ -1443,10 +1448,12 @@ type CountryDisplay { } type CreateToken { - token: String errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") - accountErrors: [AccountError!]! + token: String + refreshToken: String + csrfToken: String user: User + accountErrors: [AccountError!]! } type CreditCard { @@ -1540,6 +1547,11 @@ input DateTimeRangeInput { lte: DateTime } +type DeactivateAllUserTokens { + errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") + accountErrors: [AccountError!]! +} + scalar Decimal type DeleteMetadata { @@ -2565,9 +2577,6 @@ type Mutation { voucherCataloguesRemove(id: ID!, input: CatalogueInput!): VoucherRemoveCatalogues voucherTranslate(id: ID!, input: NameTranslationInput!, languageCode: LanguageCodeEnum!): VoucherTranslate exportProducts(input: ExportProductsInput!): ExportProducts - tokenCreate(email: String!, password: String!): CreateToken - tokenRefresh(token: String!): RefreshToken - tokenVerify(token: String!): VerifyToken checkoutAddPromoCode(checkoutId: ID!, promoCode: String!): CheckoutAddPromoCode checkoutBillingAddressUpdate(billingAddress: AddressInput!, checkoutId: ID!): CheckoutBillingAddressUpdate checkoutComplete(checkoutId: ID!, redirectUrl: String, storeSource: Boolean = false): CheckoutComplete @@ -2592,9 +2601,13 @@ type Mutation { appTokenCreate(input: AppTokenInput!): AppTokenCreate appTokenDelete(id: ID!): AppTokenDelete appTokenVerify(token: String!): AppTokenVerify + tokenCreate(email: String!, password: String!): CreateToken + tokenRefresh(csrfToken: String, refreshToken: String): RefreshToken + tokenVerify(token: String!): VerifyToken + tokensDeactivateAll: DeactivateAllUserTokens requestPasswordReset(email: String!, redirectUrl: String!): RequestPasswordReset confirmAccount(email: String!, token: String!): ConfirmAccount - setPassword(token: String!, email: String!, password: String!): SetPassword + setPassword(email: String!, password: String!, token: String!): SetPassword passwordChange(newPassword: String!, oldPassword: String!): PasswordChange requestEmailChange(newEmail: String!, password: String!, redirectUrl: String!): RequestEmailChange confirmEmailChange(token: String!): ConfirmEmailChange @@ -2869,6 +2882,7 @@ enum OrderEventsEnum { ORDER_FULLY_PAID UPDATED_ADDRESS EMAIL_SENT + PAYMENT_AUTHORIZED PAYMENT_CAPTURED PAYMENT_REFUNDED PAYMENT_VOIDED @@ -3292,8 +3306,8 @@ enum PermissionEnum { type PermissionGroupCreate { errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") - group: Group permissionGroupErrors: [PermissionGroupError!]! + group: Group } input PermissionGroupCreateInput { @@ -3342,8 +3356,8 @@ input PermissionGroupSortingInput { type PermissionGroupUpdate { errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") - group: Group permissionGroupErrors: [PermissionGroupError!]! + group: Group } input PermissionGroupUpdateInput { @@ -4095,8 +4109,10 @@ type ReducedRate { } type RefreshToken { + errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") token: String - payload: GenericScalar + user: User + accountErrors: [AccountError!]! } input ReorderInput { @@ -4346,10 +4362,12 @@ type ServiceAccountUpdatePrivateMeta { } type SetPassword { - token: String errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") - accountErrors: [AccountError!]! + token: String + refreshToken: String + csrfToken: String user: User + accountErrors: [AccountError!]! } type ShippingError { @@ -4473,8 +4491,8 @@ type ShippingZoneCountableEdge { type ShippingZoneCreate { errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") - shippingZone: ShippingZone shippingErrors: [ShippingError!]! + shippingZone: ShippingZone } input ShippingZoneCreateInput { @@ -4492,8 +4510,8 @@ type ShippingZoneDelete { type ShippingZoneUpdate { errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") - shippingZone: ShippingZone shippingErrors: [ShippingError!]! + shippingZone: ShippingZone } input ShippingZoneUpdateInput { @@ -5041,8 +5059,11 @@ type VariantPricingInfo { } type VerifyToken { - payload: GenericScalar + errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") user: User + isValid: Boolean! + payload: GenericScalar + accountErrors: [AccountError!]! } type Voucher implements Node { @@ -5262,14 +5283,14 @@ input WarehouseFilterInput { type WarehouseShippingZoneAssign { errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") - warehouse: Warehouse warehouseErrors: [WarehouseError!]! + warehouse: Warehouse } type WarehouseShippingZoneUnassign { errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") - warehouse: Warehouse warehouseErrors: [WarehouseError!]! + warehouse: Warehouse } enum WarehouseSortField { @@ -5335,8 +5356,8 @@ input WebhookCreateInput { type WebhookDelete { errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") - webhook: Webhook webhookErrors: [WebhookError!]! + webhook: Webhook } type WebhookError { @@ -5402,8 +5423,8 @@ input WebhookSortingInput { type WebhookUpdate { errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") - webhook: Webhook webhookErrors: [WebhookError!]! + webhook: Webhook } input WebhookUpdateInput { diff --git a/saleor/graphql/shipping/mutations.py b/saleor/graphql/shipping/mutations.py index 62077503c2f..57c2512c168 100644 --- a/saleor/graphql/shipping/mutations.py +++ b/saleor/graphql/shipping/mutations.py @@ -122,8 +122,6 @@ def _save_m2m(cls, info, instance, cleaned_data): class ShippingZoneCreate(ShippingZoneMixin, ModelMutation): - shipping_zone = graphene.Field(ShippingZone, description="Created shipping zone.") - class Arguments: input = ShippingZoneCreateInput( description="Fields required to create a shipping zone.", required=True @@ -138,8 +136,6 @@ class Meta: class ShippingZoneUpdate(ShippingZoneMixin, ModelMutation): - shipping_zone = graphene.Field(ShippingZone, description="Updated shipping zone.") - class Arguments: id = graphene.ID(description="ID of a shipping zone to update.", required=True) input = ShippingZoneUpdateInput( diff --git a/saleor/graphql/tests/fixtures.py b/saleor/graphql/tests/fixtures.py index d89e168ce27..fde6cf06f49 100644 --- a/saleor/graphql/tests/fixtures.py +++ b/saleor/graphql/tests/fixtures.py @@ -7,10 +7,10 @@ from django.core.serializers.json import DjangoJSONEncoder from django.shortcuts import reverse from django.test.client import MULTIPART_CONTENT, Client -from graphql_jwt.shortcuts import get_token from ...account.models import User from ...app.models import App +from ...core.jwt import create_access_token from ...tests.utils import flush_post_commit_hooks from ..views import handled_errors_logger, unhandled_errors_logger from .utils import assert_no_permission @@ -30,7 +30,7 @@ def __init__(self, *args, **kwargs): self.app_token = None self.app = app if not user.is_anonymous: - self.token = get_token(user) + self.token = create_access_token(user) elif app: token = app.tokens.first() self.app_token = token.auth_token if token else None @@ -52,7 +52,7 @@ def user(self): def user(self, user): self._user = user if not user.is_anonymous: - self.token = get_token(user) + self.token = create_access_token(user) def post(self, data=None, **kwargs): """Send a POST request. diff --git a/saleor/graphql/utils/__init__.py b/saleor/graphql/utils/__init__.py index d93294a47ea..8b5ad8e5c26 100644 --- a/saleor/graphql/utils/__init__.py +++ b/saleor/graphql/utils/__init__.py @@ -5,7 +5,6 @@ from django.db.models.functions import Concat from graphene_django.registry import get_global_registry from graphql.error import GraphQLError -from graphql_jwt.utils import jwt_payload from graphql_relay import from_global_id from ..core.enums import PermissionEnum @@ -123,14 +122,6 @@ def format_permissions_for_display(permissions): return formatted_permissions -def create_jwt_payload(user, context=None): - payload = jwt_payload(user, context) - payload["user_id"] = graphene.Node.to_global_id("User", user.id) - payload["is_staff"] = user.is_staff - payload["is_superuser"] = user.is_superuser - return payload - - def get_user_or_app_from_context(context): # order is important # app can be None but user if None then is passed as anonymous diff --git a/saleor/graphql/views.py b/saleor/graphql/views.py index f0a1755b291..852f929eec4 100644 --- a/saleor/graphql/views.py +++ b/saleor/graphql/views.py @@ -22,9 +22,9 @@ format_error as format_graphql_error, ) from graphql.execution import ExecutionResult -from graphql_jwt.exceptions import JSONWebTokenError +from jwt.exceptions import PyJWTError -from ..core.exceptions import ReadOnlyException +from ..core.exceptions import PermissionDenied, ReadOnlyException from ..core.utils import is_valid_ipv4, is_valid_ipv6 API_PATH = SimpleLazyObject(lambda: reverse("api")) @@ -59,7 +59,7 @@ class GraphQLView(View): middleware = None root_value = None - HANDLED_EXCEPTIONS = (GraphQLError, JSONWebTokenError, ReadOnlyException) + HANDLED_EXCEPTIONS = (GraphQLError, PyJWTError, ReadOnlyException, PermissionDenied) def __init__( self, schema=None, executor=None, middleware=None, root_value=None, backend=None diff --git a/saleor/graphql/warehouse/mutations.py b/saleor/graphql/warehouse/mutations.py index 9324d852cca..400fdb5e989 100644 --- a/saleor/graphql/warehouse/mutations.py +++ b/saleor/graphql/warehouse/mutations.py @@ -8,7 +8,10 @@ from ..account.i18n import I18nMixin from ..core.mutations import ModelDeleteMutation, ModelMutation from ..core.types.common import WarehouseError -from ..core.utils import validate_slug_and_generate_if_needed +from ..core.utils import ( + validate_required_string_field, + validate_slug_and_generate_if_needed, +) from ..shipping.types import ShippingZone from .types import Warehouse, WarehouseCreateInput, WarehouseUpdateInput @@ -35,6 +38,14 @@ def clean_input(cls, info, instance, data, input_cls=None): except ValidationError as error: error.code = WarehouseErrorCode.REQUIRED.value raise ValidationError({"slug": error}) + + if "name" in cleaned_input: + try: + cleaned_input = validate_required_string_field(cleaned_input, "name") + except ValidationError as error: + error.code = WarehouseErrorCode.REQUIRED.value + raise ValidationError({"name": error}) + shipping_zones = cleaned_input.get("shipping_zones", []) if not validate_warehouse_count(shipping_zones, instance): msg = "Shipping zone can be assigned only to one warehouse." @@ -69,10 +80,6 @@ def prepare_address(cls, cleaned_data, *args): class WarehouseShippingZoneAssign(WarehouseMixin, ModelMutation, I18nMixin): - warehouse = graphene.Field( - Warehouse, description="A warehouse to add shipping zone." - ) - class Meta: model = models.Warehouse permissions = (ProductPermissions.MANAGE_PRODUCTS,) @@ -99,10 +106,6 @@ def perform_mutation(cls, _root, info, **data): class WarehouseShippingZoneUnassign(WarehouseMixin, ModelMutation, I18nMixin): - warehouse = graphene.Field( - Warehouse, description="A warehouse to add shipping zone." - ) - class Meta: model = models.Warehouse permissions = (ProductPermissions.MANAGE_PRODUCTS,) diff --git a/saleor/graphql/warehouse/tests/test_warehouse.py b/saleor/graphql/warehouse/tests/test_warehouse.py index dc6588a457a..6afebff6588 100644 --- a/saleor/graphql/warehouse/tests/test_warehouse.py +++ b/saleor/graphql/warehouse/tests/test_warehouse.py @@ -339,7 +339,6 @@ def test_query_warehouses_with_filters_and_no_id( def test_mutation_create_warehouse( staff_api_client, permission_manage_products, shipping_zone ): - Warehouse.objects.all().delete() variables = { "input": { "name": "Test warehouse", @@ -374,6 +373,41 @@ def test_mutation_create_warehouse( assert created_warehouse["slug"] == warehouse.slug +def test_mutation_create_warehouse_does_not_create_when_name_is_empty_string( + staff_api_client, permission_manage_products, shipping_zone +): + variables = { + "input": { + "name": " ", + "slug": "test-warhouse", + "companyName": "Amazing Company Inc", + "email": "test-admin@example.com", + "address": { + "streetAddress1": "Teczowa 8", + "city": "Wroclaw", + "country": "PL", + "postalCode": "53-601", + }, + "shippingZones": [ + graphene.Node.to_global_id("ShippingZone", shipping_zone.id) + ], + } + } + + response = staff_api_client.post_graphql( + MUTATION_CREATE_WAREHOUSE, + variables=variables, + permissions=[permission_manage_products], + ) + content = get_graphql_content(response) + data = content["data"]["createWarehouse"] + errors = data["warehouseErrors"] + assert Warehouse.objects.count() == 0 + assert len(errors) == 1 + assert errors[0]["field"] == "name" + assert errors[0]["code"] == WarehouseErrorCode.REQUIRED.name + + def test_create_warehouse_creates_address( staff_api_client, permission_manage_products, shipping_zone ): @@ -564,14 +598,16 @@ def test_update_warehouse_slug_exists( @pytest.mark.parametrize( - "input_slug, expected_slug, input_name, error_message, error_field", + "input_slug, expected_slug, input_name, expected_name, error_message, error_field", [ - ("test-slug", "test-slug", "New name", None, None), - ("", "", "New name", "Slug value cannot be blank.", "slug"), - (None, "", "New name", "Slug value cannot be blank.", "slug"), - ("test-slug", "", None, "This field cannot be blank.", "name"), - ("test-slug", "", "", "This field cannot be blank.", "name"), - (None, None, None, "Slug value cannot be blank.", "slug"), + ("test-slug", "test-slug", "New name", "New name", None, None), + ("test-slug", "test-slug", " stripped ", "stripped", None, None,), + ("", "", "New name", "New name", "Slug value cannot be blank.", "slug"), + (None, "", "New name", "New name", "Slug value cannot be blank.", "slug"), + ("test-slug", "", None, None, "This field cannot be blank.", "name"), + ("test-slug", "", "", None, "This field cannot be blank.", "name"), + (None, None, None, None, "Slug value cannot be blank.", "slug"), + ("test-slug", "test-slug", " ", None, "Name value cannot be blank", "name"), ], ) def test_update_warehouse_slug_and_name( @@ -581,6 +617,7 @@ def test_update_warehouse_slug_and_name( input_slug, expected_slug, input_name, + expected_name, error_message, error_field, ): @@ -602,8 +639,10 @@ def test_update_warehouse_slug_and_name( data = content["data"]["updateWarehouse"] errors = data["warehouseErrors"] if not error_message: - assert data["warehouse"]["name"] == input_name == warehouse.name - assert data["warehouse"]["slug"] == input_slug == warehouse.slug + assert data["warehouse"]["name"] == expected_name == warehouse.name + assert ( + data["warehouse"]["slug"] == input_slug == warehouse.slug == expected_slug + ) else: assert errors assert errors[0]["field"] == error_field diff --git a/saleor/graphql/webhook/mutations.py b/saleor/graphql/webhook/mutations.py index d34d74029e8..51df00fe646 100644 --- a/saleor/graphql/webhook/mutations.py +++ b/saleor/graphql/webhook/mutations.py @@ -7,7 +7,6 @@ from ..core.mutations import ModelDeleteMutation, ModelMutation from ..core.types.common import WebhookError from .enums import WebhookEventTypeEnum -from .types import Webhook class WebhookCreateInput(graphene.InputObjectType): @@ -124,8 +123,6 @@ class WebhookUpdateInput(graphene.InputObjectType): class WebhookUpdate(ModelMutation): - webhook = graphene.Field(Webhook) - class Arguments: id = graphene.ID(required=True, description="ID of a webhook to update.") input = WebhookUpdateInput( @@ -164,8 +161,6 @@ def save(cls, info, instance, cleaned_input): class WebhookDelete(ModelDeleteMutation): - webhook = graphene.Field(Webhook) - class Arguments: id = graphene.ID(required=True, description="ID of a webhook to delete.") diff --git a/saleor/graphql/webhook/resolvers.py b/saleor/graphql/webhook/resolvers.py index a51a5ba4168..9624bdc6879 100644 --- a/saleor/graphql/webhook/resolvers.py +++ b/saleor/graphql/webhook/resolvers.py @@ -1,6 +1,6 @@ import graphene -from graphql_jwt.exceptions import PermissionDenied +from ...core.exceptions import PermissionDenied from ...core.permissions import WebhookPermissions from ...webhook import models, payloads from ...webhook.event_types import WebhookEventType diff --git a/saleor/order/__init__.py b/saleor/order/__init__.py index 30b7055ae25..7bc227786de 100644 --- a/saleor/order/__init__.py +++ b/saleor/order/__init__.py @@ -46,6 +46,7 @@ class OrderEvents: EMAIL_SENT = "email_sent" + PAYMENT_AUTHORIZED = "payment_authorized" PAYMENT_CAPTURED = "payment_captured" PAYMENT_REFUNDED = "payment_refunded" PAYMENT_VOIDED = "payment_voided" @@ -72,6 +73,7 @@ class OrderEvents: (ORDER_FULLY_PAID, "The order was fully paid"), (UPDATED_ADDRESS, "The address from the placed order was updated"), (EMAIL_SENT, "The email was sent"), + (PAYMENT_AUTHORIZED, "The payment was authorized"), (PAYMENT_CAPTURED, "The payment was captured"), (PAYMENT_REFUNDED, "The payment was refunded"), (PAYMENT_VOIDED, "The payment was voided"), diff --git a/saleor/order/actions.py b/saleor/order/actions.py index cad541ae359..c6b89654776 100644 --- a/saleor/order/actions.py +++ b/saleor/order/actions.py @@ -33,14 +33,26 @@ def order_created(order: "Order", user: "User", from_draft: bool = False): events.order_created_event(order=order, user=user, from_draft=from_draft) manager = get_plugins_manager() manager.order_created(order) + payment = order.get_last_payment() + if payment: + if order.is_captured(): + order_captured( + order=order, user=user, amount=payment.total, payment=payment + ) + elif order.is_pre_authorized(): + order_authorized( + order=order, user=user, amount=payment.total, payment=payment + ) + if order.is_fully_paid(): + handle_fully_paid_order(order=order, user=user) -def handle_fully_paid_order(order: "Order"): - events.order_fully_paid_event(order=order) +def handle_fully_paid_order(order: "Order", user: "User" = None): + events.order_fully_paid_event(order=order, user=user) if order.get_customer_email(): events.email_sent_event( - order=order, user=None, email_type=events.OrderEventsEmails.PAYMENT + order=order, user=user, email_type=events.OrderEventsEmails.PAYMENT ) send_payment_confirmation.delay(order.pk) @@ -116,6 +128,15 @@ def order_shipping_updated(order: "Order"): get_plugins_manager().order_updated(order) +def order_authorized( + order: "Order", user: "User", amount: "Decimal", payment: "Payment" +): + events.payment_authorized_event( + order=order, user=user, amount=amount, payment=payment + ) + get_plugins_manager().order_updated(order) + + def order_captured(order: "Order", user: "User", amount: "Decimal", payment: "Payment"): events.payment_captured_event( order=order, user=user, amount=amount, payment=payment diff --git a/saleor/order/events.py b/saleor/order/events.py index b8194577ea5..e08ba1234f9 100644 --- a/saleor/order/events.py +++ b/saleor/order/events.py @@ -140,8 +140,25 @@ def order_manually_marked_as_paid_event(*, order: Order, user: UserType) -> Orde ) -def order_fully_paid_event(*, order: Order) -> OrderEvent: - return OrderEvent.objects.create(order=order, type=OrderEvents.ORDER_FULLY_PAID) +def order_fully_paid_event(*, order: Order, user: UserType) -> OrderEvent: + if not _user_is_valid(user): + user = None + return OrderEvent.objects.create( + order=order, type=OrderEvents.ORDER_FULLY_PAID, user=user + ) + + +def payment_authorized_event( + *, order: Order, user: UserType, amount: Decimal, payment: Payment +) -> OrderEvent: + if not _user_is_valid(user): + user = None + return OrderEvent.objects.create( + order=order, + type=OrderEvents.PAYMENT_AUTHORIZED, + user=user, + **_get_payment_data(amount, payment), + ) def payment_captured_event( diff --git a/saleor/order/migrations/0084_auto_20200522_0522.py b/saleor/order/migrations/0084_auto_20200522_0522.py new file mode 100644 index 00000000000..f756b17c10d --- /dev/null +++ b/saleor/order/migrations/0084_auto_20200522_0522.py @@ -0,0 +1,44 @@ +# Generated by Django 3.0.6 on 2020-05-22 10:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("order", "0083_merge_20200421_0529"), + ] + + operations = [ + migrations.AlterField( + model_name="orderevent", + name="type", + field=models.CharField( + choices=[ + ("DRAFT_CREATED", "draft_created"), + ("DRAFT_ADDED_PRODUCTS", "draft_added_products"), + ("DRAFT_REMOVED_PRODUCTS", "draft_removed_products"), + ("PLACED", "placed"), + ("PLACED_FROM_DRAFT", "placed_from_draft"), + ("OVERSOLD_ITEMS", "oversold_items"), + ("CANCELED", "canceled"), + ("ORDER_MARKED_AS_PAID", "order_marked_as_paid"), + ("ORDER_FULLY_PAID", "order_fully_paid"), + ("UPDATED_ADDRESS", "updated_address"), + ("EMAIL_SENT", "email_sent"), + ("PAYMENT_AUTHORIZED", "payment_authorized"), + ("PAYMENT_CAPTURED", "payment_captured"), + ("PAYMENT_REFUNDED", "payment_refunded"), + ("PAYMENT_VOIDED", "payment_voided"), + ("PAYMENT_FAILED", "payment_failed"), + ("FULFILLMENT_CANCELED", "fulfillment_canceled"), + ("FULFILLMENT_RESTOCKED_ITEMS", "fulfillment_restocked_items"), + ("FULFILLMENT_FULFILLED_ITEMS", "fulfillment_fulfilled_items"), + ("TRACKING_UPDATED", "tracking_updated"), + ("NOTE_ADDED", "note_added"), + ("OTHER", "other"), + ], + max_length=255, + ), + ), + ] diff --git a/saleor/order/models.py b/saleor/order/models.py index 0f7420c76f8..a4cefa1b21d 100644 --- a/saleor/order/models.py +++ b/saleor/order/models.py @@ -244,6 +244,15 @@ def is_pre_authorized(self): .exists() ) + def is_captured(self): + return ( + self.payments.filter( + is_active=True, transactions__kind=TransactionKind.CAPTURE + ) + .filter(transactions__is_success=True) + .exists() + ) + @property def quantity_fulfilled(self): return sum([line.quantity_fulfilled for line in self]) diff --git a/saleor/payment/utils.py b/saleor/payment/utils.py index 9f7d422afe0..0b158bc5a5f 100644 --- a/saleor/payment/utils.py +++ b/saleor/payment/utils.py @@ -9,7 +9,6 @@ from ..account.models import User from ..checkout.models import Checkout -from ..order.actions import handle_fully_paid_order from ..order.models import Order from . import ChargeStatus, GatewayError, PaymentError, TransactionKind from .error_codes import PaymentErrorCode @@ -221,9 +220,6 @@ def gateway_postprocess(transaction, payment): payment.charge_status = ChargeStatus.FULLY_CHARGED payment.save(update_fields=["charge_status", "captured_amount", "modified"]) - order = payment.order - if order and order.is_fully_paid(): - handle_fully_paid_order(order) elif transaction_kind == TransactionKind.VOID: payment.is_active = False diff --git a/saleor/settings.py b/saleor/settings.py index e0c0c542a5c..00eb5600136 100644 --- a/saleor/settings.py +++ b/saleor/settings.py @@ -13,6 +13,7 @@ from django.core.exceptions import ImproperlyConfigured from django.core.management.utils import get_random_secret_key from django_prices.utils.formatting import get_currency_fraction +from pytimeparse import parse from sentry_sdk.integrations.celery import CeleryIntegration from sentry_sdk.integrations.django import DjangoIntegration @@ -213,6 +214,7 @@ def get_bool_from_env(name, default_value): "saleor.core.middleware.currency", "saleor.core.middleware.site", "saleor.core.middleware.plugins", + "saleor.core.middleware.jwt_refresh_token_middleware", ] INSTALLED_APPS = [ @@ -383,6 +385,7 @@ def get_host(): TEST_RUNNER = "saleor.tests.runner.PytestTestRunner" + PLAYGROUND_ENABLED = get_bool_from_env("PLAYGROUND_ENABLED", True) ALLOWED_HOSTS = get_list(os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1")) @@ -459,19 +462,10 @@ def get_host(): SEARCH_BACKEND = "saleor.search.backends.postgresql" AUTHENTICATION_BACKENDS = [ - "graphql_jwt.backends.JSONWebTokenBackend", + "saleor.core.auth_backend.JSONWebTokenBackend", "django.contrib.auth.backends.ModelBackend", ] -# Django GraphQL JWT settings -GRAPHQL_JWT = { - "JWT_PAYLOAD_HANDLER": "saleor.graphql.utils.create_jwt_payload", - # How long until a token expires, default is 5m from graphql_jwt.settings - "JWT_EXPIRATION_DELTA": timedelta(minutes=5), - # Whether the JWT tokens should expire or not - "JWT_VERIFY_EXPIRATION": get_bool_from_env("JWT_VERIFY_EXPIRATION", False), -} - # CELERY SETTINGS CELERY_TIMEZONE = TIME_ZONE CELERY_BROKER_URL = ( @@ -570,3 +564,13 @@ def get_host(): if REDIS_URL: CACHE_URL = os.environ.setdefault("CACHE_URL", REDIS_URL) CACHES = {"default": django_cache_url.config()} + +# Default False because storefront and dashboard don't support expiration of token +JWT_EXPIRE = get_bool_from_env("JWT_EXPIRE", False) +JWT_TTL_ACCESS = timedelta(seconds=parse(os.environ.get("JWT_TTL_ACCESS", "5 minutes"))) +JWT_TTL_REFRESH = timedelta(seconds=parse(os.environ.get("JWT_TTL_REFRESH", "30 days"))) + + +JWT_TTL_REQUEST_EMAIL_CHANGE = timedelta( + seconds=parse(os.environ.get("JWT_TTL_REQUEST_EMAIL_CHANGE", "1 hour")), +) diff --git a/saleor/tests/fixtures.py b/saleor/tests/fixtures.py index 4ca7d6ce4da..2c4d73f8ef3 100644 --- a/saleor/tests/fixtures.py +++ b/saleor/tests/fixtures.py @@ -1285,7 +1285,7 @@ def order_with_lines(order, product_type, category, shipping_zone, warehouse): ) order.shipping_address = order.billing_address.get_copy() - method = shipping_zone.shipping_methods.get() + method = shipping_zone.shipping_methods.first() order.shipping_method_name = method.name order.shipping_method = method diff --git a/saleor/tests/settings.py b/saleor/tests/settings.py index 45a7d342dd1..c6e7d844947 100644 --- a/saleor/tests/settings.py +++ b/saleor/tests/settings.py @@ -53,3 +53,5 @@ def _compile(): ] INSTALLED_APPS.append("saleor.tests") # noqa: F405 + +JWT_EXPIRE = True diff --git a/templates/templated_email/compiled/confirm_order.html b/templates/templated_email/compiled/confirm_order.html index 383c7456ec1..fb30a476759 100644 --- a/templates/templated_email/compiled/confirm_order.html +++ b/templates/templated_email/compiled/confirm_order.html @@ -104,9 +104,15 @@