diff --git a/tests/common/db/accounts.py b/tests/common/db/accounts.py index a86f52a714ef..71be86399f5c 100644 --- a/tests/common/db/accounts.py +++ b/tests/common/db/accounts.py @@ -15,7 +15,7 @@ import factory import factory.fuzzy -from warehouse.accounts.models import Email, User +from warehouse.accounts.models import Email, User, UserEvent from .base import FuzzyEmail, WarehouseFactory @@ -36,6 +36,13 @@ class Meta: last_login = factory.fuzzy.FuzzyNaiveDateTime(datetime.datetime(2011, 1, 1)) +class UserEventFactory(WarehouseFactory): + class Meta: + model = UserEvent + + user = factory.SubFactory(User) + + class EmailFactory(WarehouseFactory): class Meta: model = Email diff --git a/tests/common/db/packaging.py b/tests/common/db/packaging.py index c09d4253dad3..2c55099d0242 100644 --- a/tests/common/db/packaging.py +++ b/tests/common/db/packaging.py @@ -26,6 +26,7 @@ File, JournalEntry, Project, + ProjectEvent, Release, Role, ) @@ -43,6 +44,13 @@ class Meta: name = factory.fuzzy.FuzzyText(length=12) +class ProjectEventFactory(WarehouseFactory): + class Meta: + model = ProjectEvent + + project = factory.SubFactory(ProjectFactory) + + class DescriptionFactory(WarehouseFactory): class Meta: model = Description diff --git a/tests/unit/accounts/test_models.py b/tests/unit/accounts/test_models.py index ee816c822a98..6214b6662810 100644 --- a/tests/unit/accounts/test_models.py +++ b/tests/unit/accounts/test_models.py @@ -10,12 +10,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime + import pytest from warehouse.accounts.models import Email, User, UserFactory from ...common.db.accounts import ( EmailFactory as DBEmailFactory, + UserEventFactory as DBUserEventFactory, UserFactory as DBUserFactory, ) @@ -83,3 +86,18 @@ def test_query_by_email_when_not_primary(self, db_session): result = db_session.query(User).filter(User.email == email.email).first() assert result is None + + def test_recent_events(self, db_session): + user = DBUserFactory.create() + recent_event = DBUserEventFactory(user=user, tag="foo", ip_address="0.0.0.0") + stale_event = DBUserEventFactory( + user=user, + tag="bar", + ip_address="0.0.0.0", + time=datetime.datetime.now() - datetime.timedelta(days=15), + ) + + assert len(user.events) == 2 + assert len(user.recent_events) == 1 + assert user.events == [recent_event, stale_event] + assert user.recent_events == [recent_event] diff --git a/tests/unit/accounts/test_views.py b/tests/unit/accounts/test_views.py index 482b92bbc13b..33503c820b8b 100644 --- a/tests/unit/accounts/test_views.py +++ b/tests/unit/accounts/test_views.py @@ -159,6 +159,7 @@ def test_post_validate_redirects( find_userid=pretend.call_recorder(lambda username: user_id), update_user=pretend.call_recorder(lambda *a, **kw: None), has_two_factor=lambda userid: False, + record_event=pretend.call_recorder(lambda *a, **kw: None), ) breach_service = pretend.stub(check_password=lambda password, tags=None: False) @@ -174,6 +175,7 @@ def test_post_validate_redirects( invalidate=pretend.call_recorder(lambda: None), new_csrf_token=pretend.call_recorder(lambda: None), ) + pyramid_request.remote_addr = "0.0.0.0" pyramid_request.set_property( lambda r: str(uuid.uuid4()) if with_user else None, @@ -214,6 +216,14 @@ def test_post_validate_redirects( assert user_service.find_userid.calls == [pretend.call("theuser")] assert user_service.update_user.calls == [pretend.call(user_id, last_login=now)] + assert user_service.record_event.calls == [ + pretend.call( + user_id, + tag="account:login:success", + ip_address="0.0.0.0", + additional={"two_factor_method": None}, + ) + ] if with_user: assert new_session == {} @@ -237,6 +247,7 @@ def test_post_validate_no_redirects( find_userid=pretend.call_recorder(lambda username: 1), update_user=lambda *a, **k: None, has_two_factor=lambda userid: False, + record_event=pretend.call_recorder(lambda *a, **kw: None), ) breach_service = pretend.stub(check_password=lambda password, tags=None: False) @@ -247,6 +258,7 @@ def test_post_validate_no_redirects( pyramid_request.method = "POST" pyramid_request.POST["next"] = expected_next_url + pyramid_request.remote_addr = "0.0.0.0" form_obj = pretend.stub( validate=pretend.call_recorder(lambda: True), @@ -259,6 +271,14 @@ def test_post_validate_no_redirects( assert isinstance(result, HTTPSeeOther) assert result.headers["Location"] == observed_next_url + assert user_service.record_event.calls == [ + pretend.call( + 1, + tag="account:login:success", + ip_address="0.0.0.0", + additional={"two_factor_method": None}, + ) + ] def test_redirect_authenticated_user(self): pyramid_request = pretend.stub(authenticated_userid=1) @@ -275,6 +295,7 @@ def test_two_factor_auth(self, pyramid_request, redirect_url, token_service): find_userid=pretend.call_recorder(lambda username: 1), update_user=lambda *a, **k: None, has_two_factor=lambda userid: True, + record_event=pretend.call_recorder(lambda *a, **kw: None), ) breach_service = pretend.stub(check_password=lambda pw: False) @@ -288,6 +309,7 @@ def test_two_factor_auth(self, pyramid_request, redirect_url, token_service): pyramid_request.method = "POST" if redirect_url: pyramid_request.POST["next"] = redirect_url + pyramid_request.remote_addr = "0.0.0.0" form_obj = pretend.stub( validate=pretend.call_recorder(lambda: True), @@ -312,6 +334,7 @@ def test_two_factor_auth(self, pyramid_request, redirect_url, token_service): ("Content-Length", "0"), ("Location", "/account/two-factor"), ] + assert user_service.record_event.calls == [] class TestTwoFactor: @@ -406,6 +429,7 @@ def test_totp_auth(self, monkeypatch, pyramid_request, redirect_url): has_totp=lambda userid: True, has_webauthn=lambda userid: False, check_totp_value=lambda userid, totp_value: True, + record_event=pretend.call_recorder(lambda *a, **kw: None), ) new_session = {} @@ -416,6 +440,7 @@ def test_totp_auth(self, monkeypatch, pyramid_request, redirect_url): }[interface] pyramid_request.method = "POST" + pyramid_request.remote_addr = "0.0.0.0" pyramid_request.session = pretend.stub( items=lambda: [("a", "b"), ("foo", "bar")], update=new_session.update, @@ -451,52 +476,14 @@ def test_totp_auth(self, monkeypatch, pyramid_request, redirect_url): assert remember.calls == [pretend.call(pyramid_request, str(1))] assert pyramid_request.session.invalidate.calls == [pretend.call()] assert pyramid_request.session.new_csrf_token.calls == [pretend.call()] - - @pytest.mark.parametrize("redirect_url", ["test_redirect_url", None]) - def test_totp_auth_invalid(self, pyramid_request, redirect_url): - query_params = {"userid": str(1)} - if redirect_url: - query_params["redirect_to"] = redirect_url - - token_service = pretend.stub( - loads=pretend.call_recorder(lambda s: query_params) - ) - - user_service = pretend.stub( - find_userid=pretend.call_recorder(lambda username: 1), - update_user=lambda *a, **k: None, - has_totp=lambda userid: True, - has_webauthn=lambda userid: False, - check_totp_value=lambda userid, totp_value: False, - ) - - pyramid_request.find_service = lambda interface, **kwargs: { - ITokenService: token_service, - IUserService: user_service, - }[interface] - - pyramid_request.method = "POST" - - form_obj = pretend.stub( - validate=pretend.call_recorder(lambda: True), - totp_value=pretend.stub(data="test-otp-secret"), - ) - form_class = pretend.call_recorder(lambda d, user_service, **kw: form_obj) - pyramid_request.route_path = pretend.call_recorder( - lambda a: "/account/two-factor" - ) - pyramid_request.params = pretend.stub( - get=pretend.call_recorder(lambda k: query_params.get(k)) - ) - result = views.two_factor_and_totp_validate( - pyramid_request, _form_class=form_class - ) - - token_expected_data = {"userid": str(1)} - if redirect_url: - token_expected_data["redirect_to"] = redirect_url - - assert isinstance(result, HTTPSeeOther) + assert user_service.record_event.calls == [ + pretend.call( + "1", + tag="account:login:success", + ip_address="0.0.0.0", + additional={"two_factor_method": "totp"}, + ) + ] def test_totp_auth_already_authed(self): request = pretend.stub( @@ -698,7 +685,7 @@ def test_webauthn_validate(self, monkeypatch): ) monkeypatch.setattr(views, "_get_two_factor_data", _get_two_factor_data) - _login_user = pretend.call_recorder(lambda req, uid: pretend.stub()) + _login_user = pretend.call_recorder(lambda *a, **kw: pretend.stub()) monkeypatch.setattr(views, "_login_user", _login_user) user = pretend.stub(webauthn=pretend.stub(sign_count=pretend.stub())) @@ -738,7 +725,9 @@ def test_webauthn_validate(self, monkeypatch): result = views.webauthn_authentication_validate(request) assert _get_two_factor_data.calls == [pretend.call(request)] - assert _login_user.calls == [pretend.call(request, 1)] + assert _login_user.calls == [ + pretend.call(request, 1, two_factor_method="webauthn") + ] assert request.session.get_webauthn_challenge.calls == [pretend.call()] assert request.session.clear_webauthn_challenge.calls == [pretend.call()] @@ -858,6 +847,7 @@ def test_register_redirect(self, db_request, monkeypatch): email = pretend.stub() create_user = pretend.call_recorder(lambda *args, **kwargs: user) add_email = pretend.call_recorder(lambda *args, **kwargs: email) + record_event = pretend.call_recorder(lambda *a, **kw: None) db_request.find_service = pretend.call_recorder( lambda *args, **kwargs: pretend.stub( csp_policy={}, @@ -870,9 +860,11 @@ def test_register_redirect(self, db_request, monkeypatch): create_user=create_user, add_email=add_email, check_password=lambda pw, tags=None: False, + record_event=record_event, ) ) db_request.route_path = pretend.call_recorder(lambda name: "/") + db_request.remote_addr = "0.0.0.0" db_request.POST.update( { "username": "username_value", @@ -894,6 +886,20 @@ def test_register_redirect(self, db_request, monkeypatch): ] assert add_email.calls == [pretend.call(user.id, "foo@bar.com", primary=True)] assert send_email.calls == [pretend.call(db_request, (user, email))] + assert record_event.calls == [ + pretend.call( + user.id, + tag="account:create", + ip_address=db_request.remote_addr, + additional={"email": "foo@bar.com"}, + ), + pretend.call( + user.id, + tag="account:login:success", + ip_address=db_request.remote_addr, + additional={"two_factor_method": None}, + ), + ] def test_register_fails_with_admin_flag_set(self, db_request): # This flag was already set via migration, just need to enable it @@ -951,10 +957,12 @@ def test_request_password_reset( self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service ): - stub_user = pretend.stub(username=pretend.stub()) + stub_user = pretend.stub(id=pretend.stub(), username=pretend.stub()) pyramid_request.method = "POST" + pyramid_request.remote_addr = "0.0.0.0" token_service.dumps = pretend.call_recorder(lambda a: "TOK") user_service.get_user_by_username = pretend.call_recorder(lambda a: stub_user) + user_service.record_event = pretend.call_recorder(lambda *a, **kw: None) pyramid_request.find_service = pretend.call_recorder( lambda interface, **kw: { IUserService: user_service, @@ -991,18 +999,29 @@ def test_request_password_reset( assert send_password_reset_email.calls == [ pretend.call(pyramid_request, (stub_user, None)) ] + assert user_service.record_event.calls == [ + pretend.call( + stub_user.id, + tag="account:password:reset:request", + ip_address=pyramid_request.remote_addr, + ) + ] def test_request_password_reset_with_email( self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service ): stub_user = pretend.stub( - email="foo@example.com", emails=[pretend.stub(email="foo@example.com")] + id=pretend.stub(), + email="foo@example.com", + emails=[pretend.stub(email="foo@example.com")], ) pyramid_request.method = "POST" + pyramid_request.remote_addr = "0.0.0.0" token_service.dumps = pretend.call_recorder(lambda a: "TOK") user_service.get_user_by_username = pretend.call_recorder(lambda a: None) user_service.get_user_by_email = pretend.call_recorder(lambda a: stub_user) + user_service.record_event = pretend.call_recorder(lambda *a, **kw: None) pyramid_request.find_service = pretend.call_recorder( lambda interface, **kw: { IUserService: user_service, @@ -1040,12 +1059,20 @@ def test_request_password_reset_with_email( assert send_password_reset_email.calls == [ pretend.call(pyramid_request, (stub_user, stub_user.emails[0])) ] + assert user_service.record_event.calls == [ + pretend.call( + stub_user.id, + tag="account:password:reset:request", + ip_address=pyramid_request.remote_addr, + ) + ] def test_request_password_reset_with_non_primary_email( self, monkeypatch, pyramid_request, pyramid_config, user_service, token_service ): stub_user = pretend.stub( + id=pretend.stub(), email="foo@example.com", emails=[ pretend.stub(email="foo@example.com"), @@ -1053,9 +1080,11 @@ def test_request_password_reset_with_non_primary_email( ], ) pyramid_request.method = "POST" + pyramid_request.remote_addr = "0.0.0.0" token_service.dumps = pretend.call_recorder(lambda a: "TOK") user_service.get_user_by_username = pretend.call_recorder(lambda a: None) user_service.get_user_by_email = pretend.call_recorder(lambda a: stub_user) + user_service.record_event = pretend.call_recorder(lambda *a, **kw: None) pyramid_request.find_service = pretend.call_recorder( lambda interface, **kw: { IUserService: user_service, @@ -1095,6 +1124,13 @@ def test_request_password_reset_with_non_primary_email( assert send_password_reset_email.calls == [ pretend.call(pyramid_request, (stub_user, stub_user.emails[1])) ] + assert user_service.record_event.calls == [ + pretend.call( + stub_user.id, + tag="account:password:reset:request", + ip_address=pyramid_request.remote_addr, + ) + ] def test_redirect_authenticated_user(self): pyramid_request = pretend.stub(authenticated_userid=1) @@ -1163,6 +1199,7 @@ def test_reset_password(self, db_request, user_service, token_service): breach_service = pretend.stub(check_password=lambda pw: False) db_request.route_path = pretend.call_recorder(lambda name: "/account/login") + db_request.remote_addr = "0.0.0.0" token_service.loads = pretend.call_recorder( lambda token: { "action": "password-reset", @@ -1380,6 +1417,7 @@ def test_verify_email( db_request.user = user db_request.GET.update({"token": "RANDOM_KEY"}) db_request.route_path = pretend.call_recorder(lambda name: "/") + db_request.remote_addr = "0.0.0.0" token_service.loads = pretend.call_recorder( lambda token: {"action": "email-verify", "email.id": str(email.id)} ) diff --git a/tests/unit/manage/test_views.py b/tests/unit/manage/test_views.py index 71d7397cdb1c..abe717be5cd9 100644 --- a/tests/unit/manage/test_views.py +++ b/tests/unit/manage/test_views.py @@ -29,7 +29,14 @@ from warehouse.accounts.interfaces import IPasswordBreachedService, IUserService from warehouse.macaroons.interfaces import IMacaroonService from warehouse.manage import views -from warehouse.packaging.models import File, JournalEntry, Project, Role, User +from warehouse.packaging.models import ( + File, + JournalEntry, + Project, + ProjectEvent, + Role, + User, +) from warehouse.utils.paginate import paginate_url_factory from warehouse.utils.project import remove_documentation @@ -37,6 +44,7 @@ from ...common.db.packaging import ( FileFactory, JournalEntryFactory, + ProjectEventFactory, ProjectFactory, ReleaseFactory, RoleFactory, @@ -186,7 +194,8 @@ def test_add_email(self, monkeypatch, pyramid_config): email_address = "test@example.com" email = pretend.stub(id=pretend.stub(), email=email_address) user_service = pretend.stub( - add_email=pretend.call_recorder(lambda *a, **kw: email) + add_email=pretend.call_recorder(lambda *a, **kw: email), + record_event=pretend.call_recorder(lambda *a, **kw: None), ) request = pretend.stub( POST={"email": email_address}, @@ -197,6 +206,7 @@ def test_add_email(self, monkeypatch, pyramid_config): emails=[], username="username", name="Name", id=pretend.stub() ), task=pretend.call_recorder(lambda *args, **kwargs: send_email), + remote_addr="0.0.0.0", ) monkeypatch.setattr( views, @@ -226,6 +236,14 @@ def test_add_email(self, monkeypatch, pyramid_config): ) ] assert send_email.calls == [pretend.call(request, (request.user, email))] + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:email:add", + ip_address=request.remote_addr, + additional={"email": email_address}, + ) + ] def test_add_email_validation_fails(self, monkeypatch): email_address = "test@example.com" @@ -262,6 +280,9 @@ def test_add_email_validation_fails(self, monkeypatch): def test_delete_email(self, monkeypatch): email = pretend.stub(id=pretend.stub(), primary=False, email=pretend.stub()) some_other_email = pretend.stub() + user_service = pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ) request = pretend.stub( POST={"delete_email_id": email.id}, user=pretend.stub( @@ -272,8 +293,9 @@ def test_delete_email(self, monkeypatch): filter=lambda *a: pretend.stub(one=lambda: email) ) ), - find_service=lambda *a, **kw: pretend.stub(), + find_service=lambda *a, **kw: user_service, session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + remote_addr="0.0.0.0", ) monkeypatch.setattr( views.ManageAccountViews, "default_response", {"_": pretend.stub()} @@ -285,6 +307,14 @@ def test_delete_email(self, monkeypatch): pretend.call(f"Email address {email.email} removed", queue="success") ] assert request.user.emails == [some_other_email] + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:email:remove", + ip_address=request.remote_addr, + additional={"email": email.email}, + ) + ] def test_delete_email_not_found(self, monkeypatch): email = pretend.stub() @@ -341,13 +371,17 @@ def test_delete_email_is_primary(self, monkeypatch): def test_change_primary_email(self, monkeypatch, db_request): user = UserFactory() - old_primary = EmailFactory(primary=True, user=user) - new_primary = EmailFactory(primary=False, verified=True, user=user) + old_primary = EmailFactory(primary=True, user=user, email="old") + new_primary = EmailFactory(primary=False, verified=True, user=user, email="new") db_request.user = user - db_request.find_service = lambda *a, **kw: pretend.stub() + user_service = pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.find_service = lambda *a, **kw: user_service db_request.POST = {"primary_email_id": new_primary.id} + db_request.remote_addr = "0.0.0.0" db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) monkeypatch.setattr( views.ManageAccountViews, "default_response", {"_": pretend.stub()} @@ -367,6 +401,14 @@ def test_change_primary_email(self, monkeypatch, db_request): ] assert not old_primary.primary assert new_primary.primary + assert user_service.record_event.calls == [ + pretend.call( + user.id, + tag="account:email:primary:change", + ip_address=db_request.remote_addr, + additional={"old_primary": "old", "new_primary": "new"}, + ) + ] def test_change_primary_email_without_current(self, monkeypatch, db_request): user = UserFactory() @@ -374,8 +416,12 @@ def test_change_primary_email_without_current(self, monkeypatch, db_request): db_request.user = user - db_request.find_service = lambda *a, **kw: pretend.stub() + user_service = pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ) + db_request.find_service = lambda *a, **kw: user_service db_request.POST = {"primary_email_id": new_primary.id} + db_request.remote_addr = "0.0.0.0" db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) monkeypatch.setattr( views.ManageAccountViews, "default_response", {"_": pretend.stub()} @@ -392,6 +438,14 @@ def test_change_primary_email_without_current(self, monkeypatch, db_request): ) ] assert new_primary.primary + assert user_service.record_event.calls == [ + pretend.call( + user.id, + tag="account:email:primary:change", + ip_address=db_request.remote_addr, + additional={"old_primary": None, "new_primary": new_primary.email}, + ) + ] def test_change_primary_email_not_found(self, monkeypatch, db_request): user = UserFactory() @@ -414,7 +468,13 @@ def test_change_primary_email_not_found(self, monkeypatch, db_request): assert old_primary.primary def test_reverify_email(self, monkeypatch): - email = pretend.stub(verified=False, email="email_address") + email = pretend.stub( + verified=False, + email="email_address", + user=pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ), + ) request = pretend.stub( POST={"reverify_email_id": pretend.stub()}, @@ -426,6 +486,7 @@ def test_reverify_email(self, monkeypatch): session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), find_service=lambda *a, **kw: pretend.stub(), user=pretend.stub(id=pretend.stub(), username="username", name="Name"), + remote_addr="0.0.0.0", ) send_email = pretend.call_recorder(lambda *a: None) monkeypatch.setattr(views, "send_email_verification_email", send_email) @@ -439,6 +500,13 @@ def test_reverify_email(self, monkeypatch): pretend.call("Verification email for email_address resent", queue="success") ] assert send_email.calls == [pretend.call(request, (request.user, email))] + assert email.user.record_event.calls == [ + pretend.call( + tag="account:email:reverify", + ip_address=request.remote_addr, + additional={"email": email.email}, + ) + ] def test_reverify_email_not_found(self, monkeypatch): def raise_no_result(): @@ -499,7 +567,8 @@ def test_change_password(self, monkeypatch): old_password = "0ld_p455w0rd" new_password = "n3w_p455w0rd" user_service = pretend.stub( - update_user=pretend.call_recorder(lambda *a, **kw: None) + update_user=pretend.call_recorder(lambda *a, **kw: None), + record_event=pretend.call_recorder(lambda *a, **kw: None), ) request = pretend.stub( POST={ @@ -515,6 +584,7 @@ def test_change_password(self, monkeypatch): email=pretend.stub(), name=pretend.stub(), ), + remote_addr="0.0.0.0", ) change_pwd_obj = pretend.stub( validate=lambda: True, new_password=pretend.stub(data=new_password) @@ -540,6 +610,13 @@ def test_change_password(self, monkeypatch): assert user_service.update_user.calls == [ pretend.call(request.user.id, password=new_password) ] + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:password:change", + ip_address=request.remote_addr, + ) + ] def test_change_password_validation_fails(self, monkeypatch): old_password = "0ld_p455w0rd" @@ -847,6 +924,7 @@ def test_validate_totp_provision(self, monkeypatch): user_service = pretend.stub( get_totp_secret=lambda id: None, update_user=pretend.call_recorder(lambda *a, **kw: None), + record_event=pretend.call_recorder(lambda *a, **kw: None), ) request = pretend.stub( POST={"totp_value": "123456"}, @@ -866,6 +944,7 @@ def test_validate_totp_provision(self, monkeypatch): has_primary_verified_email=True, ), route_path=lambda *a, **kw: "/foo/bar/", + remote_addr="0.0.0.0", ) provision_totp_obj = pretend.stub(validate=lambda: True) @@ -885,6 +964,14 @@ def test_validate_totp_provision(self, monkeypatch): "Authentication application successfully set up", queue="success" ) ] + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:two_factor:method_added", + ip_address=request.remote_addr, + additional={"method": "totp"}, + ) + ] def test_validate_totp_provision_already_provisioned(self, monkeypatch): user_service = pretend.stub( @@ -990,6 +1077,7 @@ def test_delete_totp(self, monkeypatch, db_request): user_service = pretend.stub( get_totp_secret=lambda id: b"secret", update_user=pretend.call_recorder(lambda *a, **kw: None), + record_event=pretend.call_recorder(lambda *a, **kw: None), ) request = pretend.stub( POST={"confirm_username": pretend.stub()}, @@ -1004,6 +1092,7 @@ def test_delete_totp(self, monkeypatch, db_request): has_primary_verified_email=True, ), route_path=lambda *a, **kw: "/foo/bar/", + remote_addr="0.0.0.0", ) delete_totp_obj = pretend.stub(validate=lambda: True) @@ -1025,6 +1114,14 @@ def test_delete_totp(self, monkeypatch, db_request): ] assert isinstance(result, HTTPSeeOther) assert result.headers["Location"] == "/foo/bar/" + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:two_factor:method_removed", + ip_address=request.remote_addr, + additional={"method": "totp"}, + ) + ] def test_delete_totp_bad_username(self, monkeypatch, db_request): user_service = pretend.stub( @@ -1157,7 +1254,8 @@ def test_get_webauthn_options(self): def test_validate_webauthn_provision(self, monkeypatch): user_service = pretend.stub( - add_webauthn=pretend.call_recorder(lambda *a, **kw: pretend.stub()) + add_webauthn=pretend.call_recorder(lambda *a, **kw: pretend.stub()), + record_event=pretend.call_recorder(lambda *a, **kw: None), ) request = pretend.stub( POST={}, @@ -1170,6 +1268,7 @@ def test_validate_webauthn_provision(self, monkeypatch): find_service=lambda *a, **kw: user_service, domain="fake_domain", host_url="fake_host_url", + remote_addr="0.0.0.0", ) provision_webauthn_obj = pretend.stub( @@ -1204,6 +1303,17 @@ def test_validate_webauthn_provision(self, monkeypatch): pretend.call("Security device successfully set up", queue="success") ] assert result == {"success": "Security device successfully set up"} + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:two_factor:method_added", + ip_address=request.remote_addr, + additional={ + "method": "webauthn", + "label": provision_webauthn_obj.label.data, + }, + ) + ] def test_validate_webauthn_provision_invalid_form(self, monkeypatch): user_service = pretend.stub( @@ -1242,6 +1352,9 @@ def test_validate_webauthn_provision_invalid_form(self, monkeypatch): assert result == {"fail": {"errors": ["Not a real error"]}} def test_delete_webauthn(self, monkeypatch): + user_service = pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ) request = pretend.stub( POST={}, user=pretend.stub( @@ -1255,11 +1368,14 @@ def test_delete_webauthn(self, monkeypatch): ), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), route_path=pretend.call_recorder(lambda x: "/foo/bar"), - find_service=lambda *a, **kw: pretend.stub(), + find_service=lambda *a, **kw: user_service, + remote_addr="0.0.0.0", ) delete_webauthn_obj = pretend.stub( - validate=lambda: True, webauthn=pretend.stub() + validate=lambda: True, + webauthn=pretend.stub(), + label=pretend.stub(data="fake label"), ) delete_webauthn_cls = pretend.call_recorder( lambda *a, **kw: delete_webauthn_obj @@ -1275,6 +1391,17 @@ def test_delete_webauthn(self, monkeypatch): assert request.route_path.calls == [pretend.call("manage.account")] assert isinstance(result, HTTPSeeOther) assert result.headers["Location"] == "/foo/bar" + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:two_factor:method_removed", + ip_address=request.remote_addr, + additional={ + "method": "webauthn", + "label": delete_webauthn_obj.label.data, + }, + ) + ] def test_delete_webauthn_not_provisioned(self): request = pretend.stub( @@ -1467,20 +1594,107 @@ def test_create_macaroon(self, monkeypatch): lambda *a, **kw: ("not a real raw macaroon", macaroon) ) ) + user_service = pretend.stub( + record_event=pretend.call_recorder(lambda *a, **kw: None) + ) request = pretend.stub( POST={}, domain=pretend.stub(), user=pretend.stub(id=pretend.stub(), has_primary_verified_email=True), find_service=lambda interface, **kw: { IMacaroonService: macaroon_service, - IUserService: pretend.stub(), + IUserService: user_service, + }[interface], + remote_addr="0.0.0.0", + ) + + create_macaroon_obj = pretend.stub( + validate=lambda: True, + description=pretend.stub(data=pretend.stub()), + validated_scope="foobar", + ) + create_macaroon_cls = pretend.call_recorder( + lambda *a, **kw: create_macaroon_obj + ) + monkeypatch.setattr(views, "CreateMacaroonForm", create_macaroon_cls) + + project_names = [pretend.stub()] + monkeypatch.setattr( + views.ProvisionMacaroonViews, "project_names", project_names + ) + + default_response = {"default": "response"} + monkeypatch.setattr( + views.ProvisionMacaroonViews, "default_response", default_response + ) + + view = views.ProvisionMacaroonViews(request) + result = view.create_macaroon() + + assert macaroon_service.create_macaroon.calls == [ + pretend.call( + location=request.domain, + user_id=request.user.id, + description=create_macaroon_obj.description.data, + caveats={ + "permissions": create_macaroon_obj.validated_scope, + "version": 1, + }, + ) + ] + assert result == { + **default_response, + "serialized_macaroon": "not a real raw macaroon", + "macaroon": macaroon, + "create_macaroon_form": create_macaroon_obj, + } + assert user_service.record_event.calls == [ + pretend.call( + request.user.id, + tag="account:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": create_macaroon_obj.description.data, + "caveats": { + "permissions": create_macaroon_obj.validated_scope, + "version": 1, + }, + }, + ) + ] + + def test_create_macaroon_records_events_for_each_project(self, monkeypatch): + macaroon = pretend.stub() + macaroon_service = pretend.stub( + create_macaroon=pretend.call_recorder( + lambda *a, **kw: ("not a real raw macaroon", macaroon) + ) + ) + record_event = pretend.call_recorder(lambda *a, **kw: None) + user_service = pretend.stub(record_event=record_event) + request = pretend.stub( + POST={}, + domain=pretend.stub(), + user=pretend.stub( + id=pretend.stub(), + has_primary_verified_email=True, + username=pretend.stub(), + projects=[ + pretend.stub(normalized_name="foo", record_event=record_event), + pretend.stub(normalized_name="bar", record_event=record_event), + ], + ), + find_service=lambda interface, **kw: { + IMacaroonService: macaroon_service, + IUserService: user_service, }[interface], + remote_addr="0.0.0.0", ) create_macaroon_obj = pretend.stub( validate=lambda: True, description=pretend.stub(data=pretend.stub()), - validated_scope=pretend.stub(), + validated_scope={"projects": ["foo", "bar"]}, ) create_macaroon_cls = pretend.call_recorder( lambda *a, **kw: create_macaroon_obj @@ -1517,6 +1731,36 @@ def test_create_macaroon(self, monkeypatch): "macaroon": macaroon, "create_macaroon_form": create_macaroon_obj, } + assert record_event.calls == [ + pretend.call( + request.user.id, + tag="account:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": create_macaroon_obj.description.data, + "caveats": { + "permissions": create_macaroon_obj.validated_scope, + "version": 1, + }, + }, + ), + pretend.call( + tag="project:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": create_macaroon_obj.description.data, + "user": request.user.username, + }, + ), + pretend.call( + tag="project:api_token:added", + ip_address=request.remote_addr, + additional={ + "description": create_macaroon_obj.description.data, + "user": request.user.username, + }, + ), + ] def test_delete_macaroon_invalid_form(self, monkeypatch): macaroon_service = pretend.stub( @@ -1577,22 +1821,29 @@ def test_delete_macaroon_dangerous_redirect(self, monkeypatch): assert macaroon_service.delete_macaroon.calls == [] def test_delete_macaroon(self, monkeypatch): + macaroon = pretend.stub( + description="fake macaroon", caveats={"version": 1, "permissions": "user"} + ) macaroon_service = pretend.stub( delete_macaroon=pretend.call_recorder(lambda id: pretend.stub()), - find_macaroon=pretend.call_recorder( - lambda id: pretend.stub(description="fake macaroon") - ), + find_macaroon=pretend.call_recorder(lambda id: macaroon), + ) + record_event = pretend.call_recorder( + pretend.call_recorder(lambda *a, **kw: None) ) + user_service = pretend.stub(record_event=record_event) request = pretend.stub( POST={}, route_path=pretend.call_recorder(lambda x: pretend.stub()), find_service=lambda interface, **kw: { IMacaroonService: macaroon_service, - IUserService: pretend.stub(), + IUserService: user_service, }[interface], session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), referer="/fake/safe/route", host=None, + user=pretend.stub(id=pretend.stub()), + remote_addr="0.0.0.0", ) delete_macaroon_obj = pretend.stub( @@ -1618,6 +1869,96 @@ def test_delete_macaroon(self, monkeypatch): assert request.session.flash.calls == [ pretend.call("Deleted API token 'fake macaroon'.", queue="success") ] + assert record_event.calls == [ + pretend.call( + request.user.id, + tag="account:api_token:removed", + ip_address=request.remote_addr, + additional={"macaroon_id": delete_macaroon_obj.macaroon_id.data}, + ) + ] + + def test_delete_macaroon_records_events_for_each_project(self, monkeypatch): + macaroon = pretend.stub( + description="fake macaroon", + caveats={"version": 1, "permissions": {"projects": ["foo", "bar"]}}, + ) + macaroon_service = pretend.stub( + delete_macaroon=pretend.call_recorder(lambda id: pretend.stub()), + find_macaroon=pretend.call_recorder(lambda id: macaroon), + ) + record_event = pretend.call_recorder( + pretend.call_recorder(lambda *a, **kw: None) + ) + user_service = pretend.stub(record_event=record_event) + request = pretend.stub( + POST={}, + route_path=pretend.call_recorder(lambda x: pretend.stub()), + find_service=lambda interface, **kw: { + IMacaroonService: macaroon_service, + IUserService: user_service, + }[interface], + session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), + referer="/fake/safe/route", + host=None, + user=pretend.stub( + id=pretend.stub(), + username=pretend.stub(), + projects=[ + pretend.stub(normalized_name="foo", record_event=record_event), + pretend.stub(normalized_name="bar", record_event=record_event), + ], + ), + remote_addr="0.0.0.0", + ) + + delete_macaroon_obj = pretend.stub( + validate=lambda: True, macaroon_id=pretend.stub(data=pretend.stub()) + ) + delete_macaroon_cls = pretend.call_recorder( + lambda *a, **kw: delete_macaroon_obj + ) + monkeypatch.setattr(views, "DeleteMacaroonForm", delete_macaroon_cls) + + view = views.ProvisionMacaroonViews(request) + result = view.delete_macaroon() + + assert request.route_path.calls == [] + assert isinstance(result, HTTPSeeOther) + assert result.location == "/fake/safe/route" + assert macaroon_service.delete_macaroon.calls == [ + pretend.call(delete_macaroon_obj.macaroon_id.data) + ] + assert macaroon_service.find_macaroon.calls == [ + pretend.call(delete_macaroon_obj.macaroon_id.data) + ] + assert request.session.flash.calls == [ + pretend.call("Deleted API token 'fake macaroon'.", queue="success") + ] + assert record_event.calls == [ + pretend.call( + request.user.id, + tag="account:api_token:removed", + ip_address=request.remote_addr, + additional={"macaroon_id": delete_macaroon_obj.macaroon_id.data}, + ), + pretend.call( + tag="project:api_token:removed", + ip_address=request.remote_addr, + additional={ + "description": "fake macaroon", + "user": request.user.username, + }, + ), + pretend.call( + tag="project:api_token:removed", + ip_address=request.remote_addr, + additional={ + "description": "fake macaroon", + "user": request.user.username, + }, + ), + ] class TestManageProjects: @@ -1832,7 +2173,13 @@ def test_manage_project_release(self): } def test_delete_project_release(self, monkeypatch): - release = pretend.stub(version="1.2.3", project=pretend.stub(name="foobar")) + release = pretend.stub( + version="1.2.3", + canonical_version="1.2.3", + project=pretend.stub( + name="foobar", record_event=pretend.call_recorder(lambda *a, **kw: None) + ), + ) request = pretend.stub( POST={"confirm_version": release.version}, method="POST", @@ -1842,7 +2189,7 @@ def test_delete_project_release(self, monkeypatch): ), route_path=pretend.call_recorder(lambda *a, **kw: "/the-redirect"), session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)), - user=pretend.stub(), + user=pretend.stub(username=pretend.stub()), remote_addr=pretend.stub(), ) journal_obj = pretend.stub() @@ -1873,6 +2220,16 @@ def test_delete_project_release(self, monkeypatch): assert request.route_path.calls == [ pretend.call("manage.project.releases", project_name=release.project.name) ] + assert release.project.record_event.calls == [ + pretend.call( + tag="project:release:remove", + ip_address=request.remote_addr, + additional={ + "submitted_by": request.user.username, + "canonical_version": release.canonical_version, + }, + ) + ] def test_delete_project_release_no_confirm(self): release = pretend.stub(version="1.2.3", project=pretend.stub(name="foobar")) @@ -2597,6 +2954,129 @@ def test_delete_own_owner_role(self, db_request): class TestManageProjectHistory: + def test_get(self, db_request): + project = ProjectFactory.create() + older_event = ProjectEventFactory.create( + project=project, + tag="fake:event", + ip_address="0.0.0.0", + time=datetime.datetime(2017, 2, 5, 17, 18, 18, 462_634), + ) + newer_event = ProjectEventFactory.create( + project=project, + tag="fake:event", + ip_address="0.0.0.0", + time=datetime.datetime(2018, 2, 5, 17, 18, 18, 462_634), + ) + + assert views.manage_project_history(project, db_request) == { + "project": project, + "events": [newer_event, older_event], + } + + def test_raises_400_with_pagenum_type_str(self, monkeypatch, db_request): + params = MultiDict({"page": "abc"}) + db_request.params = params + + events_query = pretend.stub() + db_request.events_query = pretend.stub( + events_query=lambda *a, **kw: events_query + ) + + page_obj = pretend.stub(page_count=10, item_count=1000) + page_cls = pretend.call_recorder(lambda *a, **kw: page_obj) + monkeypatch.setattr(views, "SQLAlchemyORMPage", page_cls) + + url_maker = pretend.stub() + url_maker_factory = pretend.call_recorder(lambda request: url_maker) + monkeypatch.setattr(views, "paginate_url_factory", url_maker_factory) + + project = ProjectFactory.create() + with pytest.raises(HTTPBadRequest): + views.manage_project_history(project, db_request) + + assert page_cls.calls == [] + + def test_first_page(self, db_request): + page_number = 1 + params = MultiDict({"page": page_number}) + db_request.params = params + + project = ProjectFactory.create() + items_per_page = 25 + total_items = items_per_page + 2 + for _ in range(total_items): + ProjectEventFactory.create( + project=project, tag="fake:event", ip_address="0.0.0.0" + ) + events_query = ( + db_request.db.query(ProjectEvent) + .join(ProjectEvent.project) + .filter(ProjectEvent.project_id == project.id) + .order_by(ProjectEvent.time.desc()) + ) + + events_page = SQLAlchemyORMPage( + events_query, + page=page_number, + items_per_page=items_per_page, + item_count=total_items, + url_maker=paginate_url_factory(db_request), + ) + assert views.manage_project_history(project, db_request) == { + "project": project, + "events": events_page, + } + + def test_last_page(self, db_request): + page_number = 2 + params = MultiDict({"page": page_number}) + db_request.params = params + + project = ProjectFactory.create() + items_per_page = 25 + total_items = items_per_page + 2 + for _ in range(total_items): + ProjectEventFactory.create( + project=project, tag="fake:event", ip_address="0.0.0.0" + ) + events_query = ( + db_request.db.query(ProjectEvent) + .join(ProjectEvent.project) + .filter(ProjectEvent.project_id == project.id) + .order_by(ProjectEvent.time.desc()) + ) + + events_page = SQLAlchemyORMPage( + events_query, + page=page_number, + items_per_page=items_per_page, + item_count=total_items, + url_maker=paginate_url_factory(db_request), + ) + assert views.manage_project_history(project, db_request) == { + "project": project, + "events": events_page, + } + + def test_raises_404_with_out_of_range_page(self, db_request): + page_number = 3 + params = MultiDict({"page": page_number}) + db_request.params = params + + project = ProjectFactory.create() + items_per_page = 25 + total_items = items_per_page + 2 + for _ in range(total_items): + ProjectEventFactory.create( + project=project, tag="fake:event", ip_address="0.0.0.0" + ) + + with pytest.raises(HTTPNotFound): + assert views.manage_project_history(project, db_request) + + +class TestManageProjectJournal: def test_get(self, db_request): project = ProjectFactory.create() older_journal = JournalEntryFactory.create( @@ -2608,7 +3088,7 @@ def test_get(self, db_request): submitted_date=datetime.datetime(2018, 2, 5, 17, 18, 18, 462_634), ) - assert views.manage_project_history(project, db_request) == { + assert views.manage_project_journal(project, db_request) == { "project": project, "journals": [newer_journal, older_journal], } @@ -2632,7 +3112,7 @@ def test_raises_400_with_pagenum_type_str(self, monkeypatch, db_request): project = ProjectFactory.create() with pytest.raises(HTTPBadRequest): - views.manage_project_history(project, db_request) + views.manage_project_journal(project, db_request) assert page_cls.calls == [] @@ -2662,7 +3142,7 @@ def test_first_page(self, db_request): item_count=total_items, url_maker=paginate_url_factory(db_request), ) - assert views.manage_project_history(project, db_request) == { + assert views.manage_project_journal(project, db_request) == { "project": project, "journals": journals_page, } @@ -2693,7 +3173,7 @@ def test_last_page(self, db_request): item_count=total_items, url_maker=paginate_url_factory(db_request), ) - assert views.manage_project_history(project, db_request) == { + assert views.manage_project_journal(project, db_request) == { "project": project, "journals": journals_page, } @@ -2712,4 +3192,4 @@ def test_raises_404_with_out_of_range_page(self, db_request): ) with pytest.raises(HTTPNotFound): - assert views.manage_project_history(project, db_request) + assert views.manage_project_journal(project, db_request) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 5f4f12a00f34..8dc0c7aba96d 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -257,6 +257,13 @@ def add_policy(name, filename): traverse="/{project_name}", domain=warehouse, ), + pretend.call( + "manage.project.journal", + "/manage/project/{project_name}/journal/", + factory="warehouse.packaging.models:ProjectFactory", + traverse="/{project_name}", + domain=warehouse, + ), pretend.call( "packaging.project", "/project/{name}/", diff --git a/warehouse/accounts/interfaces.py b/warehouse/accounts/interfaces.py index 3c6899f410f7..c054297ee1cd 100644 --- a/warehouse/accounts/interfaces.py +++ b/warehouse/accounts/interfaces.py @@ -179,6 +179,14 @@ def get_webauthn_by_credential_id(user_id, credential_id): or None of the user doesn't have a credential with this ID. """ + def record_event(user_id, *, tag, ip_address, additional=None): + """ + Creates a new UserEvent for the given user with the given + tag, IP address, and additional metadata. + + Returns the event. + """ + class ITokenService(Interface): def dumps(data): diff --git a/warehouse/accounts/models.py b/warehouse/accounts/models.py index 63329eaa4e95..8c0150f1e0d5 100644 --- a/warehouse/accounts/models.py +++ b/warehouse/accounts/models.py @@ -10,6 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import enum from citext import CIText @@ -29,7 +30,7 @@ select, sql, ) -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm.exc import NoResultFound @@ -95,6 +96,18 @@ class User(SitemapMixin, db.Model): "Macaroon", backref="user", cascade="all, delete-orphan", lazy=False ) + events = orm.relationship("UserEvent", backref="user", lazy=False) + + def record_event(self, *, tag, ip_address, additional): + session = orm.object_session(self) + event = UserEvent( + user=self, tag=tag, ip_address=ip_address, additional=additional + ) + session.add(event) + session.flush() + + return event + @property def primary_email(self): primaries = [x for x in self.emails if x.primary] @@ -122,6 +135,17 @@ def has_two_factor(self): def has_primary_verified_email(self): return self.primary_email is not None and self.primary_email.verified + @property + def recent_events(self): + session = orm.object_session(self) + last_fortnight = datetime.datetime.now() - datetime.timedelta(days=14) + return ( + session.query(UserEvent) + .filter((UserEvent.user_id == self.id) & (UserEvent.time >= last_fortnight)) + .order_by(UserEvent.time.desc()) + .all() + ) + class WebAuthn(db.Model): __tablename__ = "user_security_keys" @@ -140,6 +164,20 @@ class WebAuthn(db.Model): sign_count = Column(Integer, default=0) +class UserEvent(db.Model): + __tablename__ = "user_events" + + user_id = Column( + UUID(as_uuid=True), + ForeignKey("users.id", deferrable=True, initially="DEFERRED"), + nullable=False, + ) + tag = Column(String, nullable=False) + time = Column(DateTime, nullable=False, server_default=sql.func.now()) + ip_address = Column(String, nullable=False) + additional = Column(JSONB, nullable=True) + + class UnverifyReasons(enum.Enum): SpamComplaint = "spam complaint" diff --git a/warehouse/accounts/services.py b/warehouse/accounts/services.py index 42050ef4cc3a..e4c33ee2d13e 100644 --- a/warehouse/accounts/services.py +++ b/warehouse/accounts/services.py @@ -428,6 +428,16 @@ def get_webauthn_by_credential_id(self, user_id, credential_id): None, ) + def record_event(self, user_id, *, tag, ip_address, additional=None): + """ + Creates a new UserEvent for the given user with the given + tag, IP address, and additional metadata. + + Returns the event. + """ + user = self.get_user(user_id) + return user.record_event(tag=tag, ip_address=ip_address, additional=additional) + @implementer(ITokenService) class TokenService: diff --git a/warehouse/accounts/views.py b/warehouse/accounts/views.py index e7b0016cbc72..f88c2633fff1 100644 --- a/warehouse/accounts/views.py +++ b/warehouse/accounts/views.py @@ -138,7 +138,6 @@ def login(request, redirect_field_name=REDIRECT_FIELD_NAME, _form_class=LoginFor resp = HTTPSeeOther( request.route_path("accounts.two-factor", _query=token) ) - return resp else: # If the user-originating redirection url is not safe, then @@ -171,7 +170,6 @@ def login(request, redirect_field_name=REDIRECT_FIELD_NAME, _form_class=LoginFor .hexdigest() .lower(), ) - return resp return { @@ -216,7 +214,7 @@ def two_factor_and_totp_validate(request, _form_class=TOTPAuthenticationForm): if request.method == "POST": form = two_factor_state["totp_form"] if form.validate(): - _login_user(request, userid) + _login_user(request, userid, two_factor_method="totp") resp = HTTPSeeOther(redirect_to) resp.set_cookie( @@ -295,7 +293,7 @@ def webauthn_authentication_validate(request): webauthn = user_service.get_webauthn_by_credential_id(userid, credential_id) webauthn.sign_count = sign_count - _login_user(request, userid) + _login_user(request, userid, two_factor_method="webauthn") request.response.set_cookie( USER_ID_INSECURE_COOKIE, @@ -400,6 +398,12 @@ def register(request, _form_class=RegistrationForm): form.username.data, form.full_name.data, form.new_password.data ) email = user_service.add_email(user.id, form.email.data, primary=True) + user_service.record_event( + user.id, + tag="account:create", + ip_address=request.remote_addr, + additional={"email": form.email.data}, + ) send_email_verification_email(request, (user, email)) @@ -433,6 +437,11 @@ def request_password_reset(request, _form_class=RequestPasswordResetForm): ) send_password_reset_email(request, (user, email)) + user_service.record_event( + user.id, + tag="account:password:reset:request", + ip_address=request.remote_addr, + ) token_service = request.find_service(ITokenService, name="password") n_hours = token_service.max_age // 60 // 60 @@ -507,6 +516,9 @@ def _error(message): if request.method == "POST" and form.validate(): # Update password. user_service.update_user(user.id, password=form.new_password.data) + user_service.record_event( + user.id, tag="account:password:reset", ip_address=request.remote_addr + ) # Flash a success message request.session.flash("You have reset your password", queue="success") @@ -556,6 +568,11 @@ def _error(message): email.verified = True email.unverify_reason = None email.transient_bounces = 0 + email.user.record_event( + tag="account:email:verified", + ip_address=request.remote_addr, + additional={"email": email.email, "primary": email.primary}, + ) if not email.primary: confirm_message = "You can now set this email as your primary address" @@ -586,7 +603,7 @@ def _get_two_factor_data(request, _redirect_to="/"): return two_factor_data -def _login_user(request, userid): +def _login_user(request, userid, two_factor_method=None): # We have a session factory associated with this request, so in order # to protect against session fixation attacks we're going to make sure # that we create a new session (which for sessions with an identifier @@ -625,6 +642,12 @@ def _login_user(request, userid): # records when the last login was. user_service = request.find_service(IUserService, context=None) user_service.update_user(userid, last_login=datetime.datetime.utcnow()) + user_service.record_event( + userid, + tag="account:login:success", + ip_address=request.remote_addr, + additional={"two_factor_method": two_factor_method}, + ) return headers diff --git a/warehouse/admin/templates/admin/projects/detail.html b/warehouse/admin/templates/admin/projects/detail.html index fdf5ce618eb5..f4794115902e 100644 --- a/warehouse/admin/templates/admin/projects/detail.html +++ b/warehouse/admin/templates/admin/projects/detail.html @@ -228,6 +228,31 @@
Event | +Time | +IP address | +Additional information | + + + {% for event in project.events %} +
---|---|---|---|
{{ event.tag }} | +{{ event.time }} | +{{ event.ip_address }} | +{{ event.additional }} | +
Event | +Time | +IP address | +Additional information | + + + {% for event in user.recent_events %} +
---|---|---|---|
{{ event.tag }} | +{{ event.time }} | +{{ event.ip_address }} | +{{ event.additional }} | +
Events appear here as security-related actions occur on your account.
+Event | +Date / time | +IP address | + + + {% for event in recent_events %} +
---|---|---|
{{ event_summary(event) }} | +{{ humanize(event.time) }} | +{{ event.ip_address }} | +
Events will appear here as security-related actions occur on your account.
+ {% endif %} +Each time you or your collaborators update this project, the action is recorded and displayed here
+Each time you (or your collaborators) perform a security action related to this project, the action is recorded and displayed here.
-Action | -Date | -User | +Event | +Date / time | +IP address |
---|---|---|---|---|---|
- {% if journal.version %} Release {{ journal.version }}: {% endif %}{{ journal.action }} - | -- {{ journal.submitted_date|format_datetime() }} - | -- {{ journal.submitted_by.username }} - from {{ journal.submitted_from }} - | +{{ event_summary(event) }} | +{{ humanize(event.time) }} | +{{ event.ip_address }} |
Each time you or your collaborators update this project, the action is recorded and displayed here.
+ +This feature will be deprecated in the future, replaced by the security history page. +
Action | +Date | +User | +
---|---|---|
+ {% if journal.version %} Release {{ journal.version }}: {% endif %}{{ journal.action }} + | ++ {{ journal.submitted_date|format_datetime() }} + | ++ {{ journal.submitted_by.username }} + from {{ journal.submitted_from }} + | +