diff --git a/pyproject.toml b/pyproject.toml index bc030d39192b..20c058e0d23c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ module = [ "pyqrcode.*", "requests_aws4auth.*", # https://github.com/tedder/requests-aws4auth/issues/53 "rfc3986.*", # https://github.com/python-hyper/rfc3986/issues/122 + "social_pyramid.models", "transaction.*", "ua_parser.*", # https://github.com/ua-parser/uap-python/issues/110 "venusian.*", diff --git a/requirements/main.in b/requirements/main.in index b16f8e39d9d9..93e4b46fdb2f 100644 --- a/requirements/main.in +++ b/requirements/main.in @@ -65,6 +65,7 @@ rfc3986 sentry-sdk setuptools pypi-attestations==0.0.25 +social-auth-app-pyramid sqlalchemy[asyncio]>=2.0,<3.0 stdlib-list stripe diff --git a/requirements/main.txt b/requirements/main.txt index a1340e40ebda..a354f035522d 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -517,6 +517,7 @@ cryptography==44.0.2 \ # pypi-attestations # rfc3161-client # sigstore + # social-auth-core # webauthn cssselect==1.3.0 \ --hash=sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d \ @@ -530,6 +531,12 @@ datadog==0.51.0 \ --hash=sha256:3279534f831ae0b4ae2d8ce42ef038b4ab38e667d7ed6ff7437982d7a0cf5250 \ --hash=sha256:a9764f091c96af4e0996d4400b168fc5fba380f911d6d672c9dcd4773e29ea3f # via -r requirements/main.in +defusedxml==0.7.1 \ + --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ + --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 + # via + # python3-openid + # social-auth-core deprecated==1.2.18 \ --hash=sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d \ --hash=sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec @@ -1458,6 +1465,12 @@ nh3==0.2.21 \ # via # -r requirements/main.in # readme-renderer +oauthlib==3.2.2 \ + --hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \ + --hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918 + # via + # requests-oauthlib + # social-auth-core openapi-core==0.19.5 \ --hash=sha256:421e753da56c391704454e66afe4803a290108590ac8fa6f4a4487f4ec11f2d3 \ --hash=sha256:ef7210e83a59394f46ce282639d8d26ad6fc8094aa904c9c16eb1bac8908911f @@ -1790,6 +1803,7 @@ pyjwt[crypto]==2.10.1 \ # -r requirements/main.in # pyjwt # sigstore + # social-auth-core pymacaroons==0.13.0 \ --hash=sha256:1e6bba42a5f66c245adf38a5a4006a99dcc06a0703786ea636098667d42903b8 \ --hash=sha256:3e14dff6a262fdbf1a15e769ce635a8aea72e6f8f91e408f9a97166c53b91907 @@ -1883,6 +1897,10 @@ python-slugify==8.0.4 \ --hash=sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8 \ --hash=sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856 # via -r requirements/main.in +python3-openid==3.2.0 \ + --hash=sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf \ + --hash=sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b + # via social-auth-core pytz==2025.2 \ --hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \ --hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00 @@ -1983,7 +2001,9 @@ requests==2.32.3 \ # pypi-attestations # requests-aws4auth # requests-file + # requests-oauthlib # sigstore + # social-auth-core # stripe # tldextract requests-aws4auth==1.3.1 \ @@ -1994,6 +2014,10 @@ requests-file==2.1.0 \ --hash=sha256:0f549a3f3b0699415ac04d167e9cb39bccfb730cb832b4d20be3d9867356e658 \ --hash=sha256:cf270de5a4c5874e84599fc5778303d496c10ae5e870bfa378818f35d21bda5c # via tldextract +requests-oauthlib==2.0.0 \ + --hash=sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36 \ + --hash=sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9 + # via social-auth-core rfc3161-client==1.0.1 \ --hash=sha256:081211a1b602b6dff7feb314d39ca2229c8db4e8cf55eef0c35b460470f4b2bb \ --hash=sha256:0d3db059fe08d8b6b06aff89e133fcc352ffea1a1dafadb116dda9dae59d0689 \ @@ -2183,6 +2207,23 @@ six==1.17.0 \ # pymacaroons # python-dateutil # rfc3339-validator + # social-auth-app-pyramid + # social-auth-storage-sqlalchemy +social-auth-app-pyramid==2.0.0 \ + --hash=sha256:1bf21f0ff51a338cd6ca944d49509d042903dbae76b51bd54ce6c44eea9906b4 \ + --hash=sha256:6f3a0f35ad0d226c7d23bec1e17790e36a7758aed3946a79cd94e1f917a6dbe0 + # via -r requirements/main.in +social-auth-core==4.6.0 \ + --hash=sha256:6d940c458c529d3689f2ceca2d944911a4b02d76e82d154688d1d3f75f12d168 \ + --hash=sha256:ecf9ae1e2e5bb52741cedcaede943fe89a8ada23f9aad018f350d4839a9d3a90 + # via + # social-auth-app-pyramid + # social-auth-storage-sqlalchemy +social-auth-storage-sqlalchemy==1.1.0 \ + --hash=sha256:0f408106bacf22794628e42d95e104f29044cf21e7b894c3913e76d2ec0eaa3b \ + --hash=sha256:3598835f33719e76a846eac69f1f49820f4f2c8edc86cc6479d5099457ec6121 \ + --hash=sha256:6ccfe0502e07086eccdb606875106d7e2a29b9804d3c7b31d0f53ba6a50e8e29 + # via social-auth-app-pyramid sqlalchemy[asyncio]==2.0.40 \ --hash=sha256:00a494ea6f42a44c326477b5bee4e0fc75f6a80c01570a32b57e89cf0fbef85a \ --hash=sha256:0bb933a650323e476a2e4fbef8997a10d0003d4da996aad3fd7873e962fdde4d \ @@ -2246,6 +2287,7 @@ sqlalchemy[asyncio]==2.0.40 \ # alembic # alembic-postgresql-enum # paginate-sqlalchemy + # social-auth-storage-sqlalchemy # zope-sqlalchemy stdlib-list==0.11.1 \ --hash=sha256:9029ea5e3dfde8cd4294cfd4d1797be56a67fc4693c606181730148c3fd1da29 \ diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 6ecfeec7b37a..a63fafaff5d7 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -288,7 +288,7 @@ def __init__(self): whitenoise_serve_static=pretend.call_recorder(lambda *a, **kw: None), whitenoise_add_files=pretend.call_recorder(lambda *a, **kw: None), whitenoise_add_manifest=pretend.call_recorder(lambda *a, **kw: None), - scan=pretend.call_recorder(lambda categories, ignore: None), + scan=pretend.call_recorder(lambda categories, ignore=False: None), commit=pretend.call_recorder(lambda: None), add_view_deriver=pretend.call_recorder(lambda *a, **kw: None), ) @@ -429,6 +429,7 @@ def __init__(self): pretend.call(".cache"), pretend.call(".email"), pretend.call(".accounts"), + pretend.call("social_pyramid"), pretend.call(".macaroons"), pretend.call(".oidc"), pretend.call(".attestations"), @@ -520,6 +521,7 @@ def __init__(self): pretend.call( categories=( "pyramid", + "social_pyramid", "warehouse", ), ignore=["warehouse.migrations.env", "warehouse.celery", "warehouse.wsgi"], diff --git a/warehouse/config.py b/warehouse/config.py index 7a64680be1f0..c9d184731e2e 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -32,9 +32,11 @@ from pyramid.httpexceptions import HTTPBadRequest from pyramid.tweens import EXCVIEW from pyramid_rpc.xmlrpc import XMLRPCRenderer +from social_pyramid.models import init_social from warehouse.authnz import Permissions from warehouse.constants import MAX_FILESIZE, MAX_PROJECT_SIZE, ONE_GIB, ONE_MIB +from warehouse.db import ModelBase, Session, metadata from warehouse.utils.static import ManifestCacheBuster from warehouse.utils.wsgi import ProxyFixer, VhmRootRemover @@ -821,6 +823,14 @@ def configure(settings=None): # Register our authentication support. config.include(".accounts") + # https://python-social-auth.readthedocs.io/en/latest/configuration/settings.html + config.include("social_pyramid") + config.registry.settings["SOCIAL_AUTH_USER_MODEL"] = ( + "warehouse.accounts.models.User" + ) + if "social_auth_usersocialauth" not in metadata.tables: + init_social(config, ModelBase, Session) + # Register support for Macaroon based authentication config.include(".macaroons") @@ -951,6 +961,7 @@ def configure(settings=None): config.scan( categories=( "pyramid", + "social_pyramid", "warehouse", ), ignore=["warehouse.migrations.env", "warehouse.celery", "warehouse.wsgi"], diff --git a/warehouse/migrations/versions/fcb2e13374bb_social_auth_models.py b/warehouse/migrations/versions/fcb2e13374bb_social_auth_models.py new file mode 100644 index 000000000000..b8ac244caa8e --- /dev/null +++ b/warehouse/migrations/versions/fcb2e13374bb_social_auth_models.py @@ -0,0 +1,112 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +social auth models + +Revision ID: fcb2e13374bb +Revises: c8384ca429fc +Create Date: 2025-04-27 16:01:36.308879 +""" + +import social_sqlalchemy +import sqlalchemy as sa + +from alembic import op + +revision = "fcb2e13374bb" +down_revision = "13c1c0ac92e9" + + +def upgrade(): + op.create_table( + "social_auth_association", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("server_url", sa.String(length=255), nullable=True), + sa.Column("handle", sa.String(length=255), nullable=True), + sa.Column("secret", sa.String(length=255), nullable=True), + sa.Column("issued", sa.Integer(), nullable=True), + sa.Column("lifetime", sa.Integer(), nullable=True), + sa.Column("assoc_type", sa.String(length=64), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("server_url", "handle"), + ) + op.create_table( + "social_auth_code", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=200), nullable=True), + sa.Column("code", sa.String(length=32), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("code", "email"), + ) + op.create_index( + op.f("ix_social_auth_code_code"), "social_auth_code", ["code"], unique=False + ) + op.create_table( + "social_auth_nonce", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("server_url", sa.String(length=255), nullable=True), + sa.Column("timestamp", sa.Integer(), nullable=True), + sa.Column("salt", sa.String(length=40), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("server_url", "timestamp", "salt"), + ) + op.create_table( + "social_auth_partial", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(length=32), nullable=True), + sa.Column("data", social_sqlalchemy.storage.JSONType(), nullable=True), + sa.Column("next_step", sa.Integer(), nullable=True), + sa.Column("backend", sa.String(length=32), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_social_auth_partial_token"), + "social_auth_partial", + ["token"], + unique=False, + ) + op.create_table( + "social_auth_usersocialauth", + sa.Column("uid", sa.String(length=255), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("provider", sa.String(length=32), nullable=True), + sa.Column("extra_data", social_sqlalchemy.storage.JSONType(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("provider", "uid"), + ) + op.create_index( + op.f("ix_social_auth_usersocialauth_user_id"), + "social_auth_usersocialauth", + ["user_id"], + unique=False, + ) + + +def downgrade(): + op.drop_index( + op.f("ix_social_auth_usersocialauth_user_id"), + table_name="social_auth_usersocialauth", + ) + op.drop_table("social_auth_usersocialauth") + op.drop_index( + op.f("ix_social_auth_partial_token"), table_name="social_auth_partial" + ) + op.drop_table("social_auth_partial") + op.drop_table("social_auth_nonce") + op.drop_index(op.f("ix_social_auth_code_code"), table_name="social_auth_code") + op.drop_table("social_auth_code") + op.drop_table("social_auth_association") diff --git a/warehouse/templates/manage/account.html b/warehouse/templates/manage/account.html index eae32be191ba..5690d345e2b4 100644 --- a/warehouse/templates/manage/account.html +++ b/warehouse/templates/manage/account.html @@ -501,6 +501,33 @@
+ {% trans %} + Associating Third-Party Accounts with your PyPI User provides a mechanism for PyPI Support and Administrators + to validate your identity in the event that you lose access to your email, 2FA, or recovery codes. + {% endtrans %} +
++ {% trans %} + These associations do not permit login to PyPI and are only used for verification. + {% endtrans %} +
+ + {% for provider in social_auth_providers_available %} + + {% endfor %} + + GitHub + GitLab + Bitbucket + Gitea + +