Skip to content

Commit

Permalink
feat: admin flag to require 2fa (pypi#15017)
Browse files Browse the repository at this point in the history
  • Loading branch information
miketheman authored Dec 6, 2023
1 parent 7ce1a6e commit f695f5a
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 3 deletions.
44 changes: 44 additions & 0 deletions docs/blog/posts/2023-12-06-2fa-enforcement-on-testpypi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: 2FA Enforcement for TestPyPI
description: PyPI requires 2FA for all management actions on TestPyPI.
author: Mike Fiedler
publish_date: 2023-12-06
date: "2023-12-06 00:00"
tags:
- 2fa
- security
---

## What's changing?

Starting today, **all users must enable 2FA**
before they can perform any management actions on [TestPyPI](https://test.pypi.org/).

This change is in preparation for the
[scheduled enforcement of 2FA on PyPI](2023-05-25-securing-pypi-with-2fa.md)
at the end of 2023.

Previously the PyPI team has announced
[2FA requirement for uploads](2023-06-01-2fa-enforcement-for-upload.md),
[2FA requirement for new user registrations on PyPI](2023-08-08-2fa-enforcement-for-new-users.md),
and now the requirement extends that **all users** on TestPyPI.

## How does this affect me?

If you only need to browse, download, and install packages from TestPyPI
then a TestPyPI account isn't needed so this change does not affect you.

If you've already enabled 2FA on your TestPyPI account,
this change will not affect you.

If you recently registered a new TestPyPI account,
you are required to enable 2FA before you can perform any management actions.
When attempting to perform a management action,
you may see a red banner flash at the top of the page,
and be redirected to the 2FA setup page for your account.
You will still be able to log in, browse, and download packages without 2FA.
But to perform any management actions, you'll need to enable 2FA.

Visit [the 2FA FAQ](https://pypi.org/help/#twofa) for more details.

_Mike Fiedler is the PyPI Safety & Security Engineer since 2023._
16 changes: 16 additions & 0 deletions tests/unit/accounts/test_security_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from warehouse.accounts import security_policy
from warehouse.accounts.interfaces import IUserService
from warehouse.admin.flags import AdminFlagValue
from warehouse.utils.security_policy import AuthenticationMethod


Expand Down Expand Up @@ -571,6 +572,7 @@ def test_acl(self, monkeypatch, policy_class, principals, expected):
monkeypatch.setattr(security_policy, "User", pretend.stub)

request = pretend.stub(
flags=pretend.stub(enabled=lambda flag: False),
identity=pretend.stub(
__principals__=lambda: principals,
has_primary_verified_email=True,
Expand Down Expand Up @@ -600,6 +602,7 @@ def test_2fa_owner_requires(
monkeypatch.setattr(security_policy, "TwoFactorRequireable", pretend.stub)

request = pretend.stub(
flags=pretend.stub(enabled=lambda flag: False),
identity=pretend.stub(
__principals__=lambda: ["user:5"],
has_primary_verified_email=True,
Expand Down Expand Up @@ -638,6 +641,7 @@ def test_2fa_pypi_mandates_2fa(
monkeypatch.setattr(security_policy, "TwoFactorRequireable", pretend.stub)

request = pretend.stub(
flags=pretend.stub(enabled=lambda flag: False),
identity=pretend.stub(
__principals__=lambda: ["user:5"],
has_primary_verified_email=True,
Expand Down Expand Up @@ -676,6 +680,7 @@ def test_2fa_pypi_mandates_2fa_with_warning(
monkeypatch.setattr(security_policy, "TwoFactorRequireable", pretend.stub)

request = pretend.stub(
flags=pretend.stub(enabled=lambda flag: False),
identity=pretend.stub(
__principals__=lambda: ["user:5"],
has_primary_verified_email=True,
Expand Down Expand Up @@ -734,6 +739,7 @@ def test_permits_manage_projects_without_2fa_for_older_users(
monkeypatch.setattr(security_policy, "User", pretend.stub)

request = pretend.stub(
flags=pretend.stub(enabled=lambda flag: False),
identity=pretend.stub(
__principals__=lambda: ["user:5"],
has_primary_verified_email=True,
Expand All @@ -751,6 +757,7 @@ def test_permits_manage_projects_with_2fa(self, monkeypatch, policy_class):
monkeypatch.setattr(security_policy, "User", pretend.stub)

request = pretend.stub(
flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: True)),
identity=pretend.stub(
__principals__=lambda: ["user:5"],
has_primary_verified_email=True,
Expand All @@ -763,11 +770,15 @@ def test_permits_manage_projects_with_2fa(self, monkeypatch, policy_class):

policy = policy_class()
assert policy.permits(request, context, "myperm")
assert request.flags.enabled.calls == [
pretend.call(AdminFlagValue.TWOFA_REQUIRED_EVERYWHERE)
]

def test_deny_manage_projects_without_2fa(self, monkeypatch, policy_class):
monkeypatch.setattr(security_policy, "User", pretend.stub)

request = pretend.stub(
flags=pretend.stub(enabled=lambda flag: False),
identity=pretend.stub(
__principals__=lambda: ["user:5"],
has_primary_verified_email=True,
Expand All @@ -785,6 +796,7 @@ def test_deny_forklift_file_upload_without_2fa(self, monkeypatch, policy_class):
monkeypatch.setattr(security_policy, "User", pretend.stub)

request = pretend.stub(
flags=pretend.stub(enabled=lambda flag: False),
identity=pretend.stub(
__principals__=lambda: ["user:5"],
has_primary_verified_email=True,
Expand Down Expand Up @@ -815,6 +827,7 @@ def test_permits_2fa_routes_without_2fa(
monkeypatch.setattr(security_policy, "User", pretend.stub)

request = pretend.stub(
flags=pretend.stub(enabled=pretend.call_recorder(lambda *a: False)),
identity=pretend.stub(
__principals__=lambda: ["user:5"],
has_primary_verified_email=True,
Expand All @@ -828,3 +841,6 @@ def test_permits_2fa_routes_without_2fa(

policy = policy_class()
assert policy.permits(request, context, "myperm")
assert request.flags.enabled.calls == [
pretend.call(AdminFlagValue.TWOFA_REQUIRED_EVERYWHERE)
]
8 changes: 5 additions & 3 deletions warehouse/accounts/security_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from warehouse.accounts.interfaces import IPasswordBreachedService, IUserService
from warehouse.accounts.models import DisableReason, User
from warehouse.admin.flags import AdminFlagValue
from warehouse.cache.http import add_vary_callback
from warehouse.email import send_password_compromised_email_hibp
from warehouse.errors import (
Expand Down Expand Up @@ -332,9 +333,10 @@ def _check_for_mfa(request, context) -> WarehouseDenied | None:
"manage.account.webauthn-provision",
]

# Start enforcement from 2023-08-08, but we should remove this check
# at the end of 2023.
if (
# If flag is active, require 2FA for management and upload.
if request.flags.enabled(AdminFlagValue.TWOFA_REQUIRED_EVERYWHERE) or (
# Start enforcement from 2023-08-08, but we should remove this check
# at the end of 2023.
request.identity.date_joined
and request.identity.date_joined > datetime.datetime(2023, 8, 8)
):
Expand Down
1 change: 1 addition & 0 deletions warehouse/admin/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class AdminFlagValue(enum.Enum):
DISALLOW_GITHUB_OIDC = "disallow-github-oidc"
DISALLOW_GOOGLE_OIDC = "disallow-google-oidc"
READ_ONLY = "read-only"
TWOFA_REQUIRED_EVERYWHERE = "2fa-required"


class AdminFlag(db.ModelBase):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 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.
"""
Admin Flag: 2fa-required
Revision ID: 0940ed80e40a
Revises: 4297620f7b41
Create Date: 2023-12-05 23:44:58.113194
"""

from alembic import op

revision = "0940ed80e40a"
down_revision = "4297620f7b41"


def upgrade():
op.execute(
"""
INSERT INTO admin_flags(id, description, enabled, notify)
VALUES (
'2fa-required',
'Require 2FA for all users',
FALSE,
FALSE
)
"""
)


def downgrade():
op.execute("DELETE FROM admin_flags WHERE id = '2fa-required'")

0 comments on commit f695f5a

Please sign in to comment.