Skip to content

Commit

Permalink
2FA (TOTP) support (#5567)
Browse files Browse the repository at this point in the history
  • Loading branch information
woodruffw authored and dstufft committed May 4, 2019
1 parent e2d648e commit 6cbaf84
Show file tree
Hide file tree
Showing 40 changed files with 1,673 additions and 69 deletions.
2 changes: 1 addition & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ omit =

[report]
exclude_lines =
# pragma: no cover
pragma: no cover
class \w+\(Interface\):
1 change: 1 addition & 0 deletions dev/environment
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ STATUSPAGE_URL=https://2p66nmmycsj3.statuspage.io

TOKEN_PASSWORD_SECRET="an insecure password reset secret key"
TOKEN_EMAIL_SECRET="an insecure email verification secret key"
TOKEN_TWO_FACTOR_SECRET="an insecure two-factor auth secret key"

WAREHOUSE_LEGACY_DOMAIN=pypi.python.org
1 change: 1 addition & 0 deletions requirements/main.in
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ passlib>=1.6.4
premailer
psycopg2
pycurl
pyqrcode
pyramid>=1.9,<1.11.0
pyramid_jinja2>=2.5
pyramid_mailer>=0.14.1
Expand Down
3 changes: 3 additions & 0 deletions requirements/main.txt
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,9 @@ pycurl==7.43.0.2 \
--hash=sha256:df6f42787c39304522a00ecaa0a7fac5daffd0aa89044c68fc9fd5d683dd2ed5 \
--hash=sha256:eccea049aef47decc380746b3ff242d95636d578c907d0eab3b00918292d6c48 \
--hash=sha256:fe12f59e7bc6c217f12c6726c2617238fd4c0d53b28db956de592252da4e5bb0
pyqrcode==1.2.1 \
--hash=sha256:1b2812775fa6ff5c527977c4cd2ccb07051ca7d0bc0aecf937a43864abe5eff6 \
--hash=sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5
pygments==2.3.1 \
--hash=sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a \
--hash=sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d
Expand Down
1 change: 1 addition & 0 deletions tests/common/db/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class Meta:
datetime.datetime(2005, 1, 1), datetime.datetime(2010, 1, 1)
)
last_login = factory.fuzzy.FuzzyNaiveDateTime(datetime.datetime(2011, 1, 1))
two_factor_allowed = True


class EmailFactory(WarehouseFactory):
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/accounts/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,9 @@ def test_includeme(monkeypatch):
TokenServiceFactory(name="password"), ITokenService, name="password"
),
pretend.call(TokenServiceFactory(name="email"), ITokenService, name="email"),
pretend.call(
TokenServiceFactory(name="two_factor"), ITokenService, name="two_factor"
),
pretend.call(
HaveIBeenPwnedPasswordBreachedService.create_service,
IPasswordBreachedService,
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/accounts/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,3 +553,42 @@ def test_password_breached(self):
"This password has appeared in a breach or has otherwise been "
"compromised and cannot be used."
)


class TestTwoFactorForm:
def test_creation(self):
user_id = pretend.stub()
user_service = pretend.stub()
form = forms.TwoFactorForm(user_id=user_id, user_service=user_service)

assert form.user_service is user_service

def test_totp_secret_exists(self):
form = forms.TwoFactorForm(
data={"totp_value": ""}, user_id=pretend.stub(), user_service=pretend.stub()
)
assert not form.validate()
assert form.totp_value.errors.pop() == "This field is required."

form = forms.TwoFactorForm(
data={"totp_value": "not_a_real_value"},
user_id=pretend.stub(),
user_service=pretend.stub(check_totp_value=lambda *a: True),
)
assert not form.validate()
assert form.totp_value.errors.pop() == "TOTP code must be 6 digits."

form = forms.TwoFactorForm(
data={"totp_value": "123456"},
user_id=pretend.stub(),
user_service=pretend.stub(check_totp_value=lambda *a: False),
)
assert not form.validate()
assert form.totp_value.errors.pop() == "Invalid TOTP code."

form = forms.TwoFactorForm(
data={"totp_value": "123456"},
user_id=pretend.stub(),
user_service=pretend.stub(check_totp_value=lambda *a: True),
)
assert form.validate()
22 changes: 22 additions & 0 deletions tests/unit/accounts/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

from zope.interface.verify import verifyClass

import warehouse.utils.otp as otp

from warehouse.accounts import services
from warehouse.accounts.interfaces import (
IPasswordBreachedService,
Expand Down Expand Up @@ -342,6 +344,26 @@ def test_updating_password_undisables(self, user_service):
user_service.update_user(user.id, password="foo")
assert user_service.is_disabled(user.id) == (False, None)

def test_has_two_factor(self, user_service):
user = UserFactory.create()
assert not user_service.has_two_factor(user.id)
user_service.update_user(user.id, totp_secret=b"foobar")
assert user_service.has_two_factor(user.id)

@pytest.mark.parametrize("valid", [True, False])
def test_check_totp_value(self, user_service, monkeypatch, valid):
verify_totp = pretend.call_recorder(lambda *a: valid)
monkeypatch.setattr(otp, "verify_totp", verify_totp)

user = UserFactory.create()
user_service.update_user(user.id, totp_secret=b"foobar")

assert user_service.check_totp_value(user.id, b"123456") == valid

def test_check_totp_value_no_secret(self, user_service):
user = UserFactory.create()
assert not user_service.check_totp_value(user.id, b"123456")


class TestTokenService:
def test_verify_service(self):
Expand Down
Loading

0 comments on commit 6cbaf84

Please sign in to comment.