Skip to content

Commit 12d25d5

Browse files
authored
Merge pull request #1329 from dstufft/analytics
Implement Browser Analytics
2 parents 49669f7 + 3d4136b commit 12d25d5

File tree

13 files changed

+224
-25
lines changed

13 files changed

+224
-25
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"babel-preset-es2015-native-modules": "6.6.0",
1111
"babel-register": "6.7.2",
1212
"clipboard": "1.5.10",
13+
"cookie": "0.3.1",
1314
"del": "2.2.0",
1415
"exports-loader": "0.6.3",
1516
"font-awesome": "4.5.0",

tests/unit/accounts/test_services.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13+
import uuid
14+
1315
import pretend
1416

1517
from zope.interface.verify import verifyClass
@@ -60,7 +62,7 @@ def test_find_userid_existing_user(self, db_session):
6062

6163
def test_check_password_nonexistant_user(self, db_session):
6264
service = services.DatabaseUserService(db_session)
63-
assert not service.check_password(1, None)
65+
assert not service.check_password(uuid.uuid4(), None)
6466

6567
def test_check_password_invalid(self, db_session):
6668
user = UserFactory.create()

tests/unit/accounts/test_views.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
# limitations under the License.
1212

1313
import datetime
14+
import uuid
1415

1516
import freezegun
1617
import pretend
@@ -118,8 +119,9 @@ def test_post_validate_redirects(self, monkeypatch, pyramid_request,
118119

119120
new_session = {}
120121

122+
user_id = uuid.uuid4()
121123
user_service = pretend.stub(
122-
find_userid=pretend.call_recorder(lambda username: 1),
124+
find_userid=pretend.call_recorder(lambda username: user_id),
123125
update_user=pretend.call_recorder(lambda *a, **kw: None),
124126
)
125127
pyramid_request.find_service = pretend.call_recorder(
@@ -134,7 +136,7 @@ def test_post_validate_redirects(self, monkeypatch, pyramid_request,
134136
)
135137

136138
pyramid_request.set_property(
137-
lambda r: 1234 if with_user else None,
139+
lambda r: str(uuid.uuid4()) if with_user else None,
138140
name="unauthenticated_userid",
139141
)
140142

@@ -161,15 +163,15 @@ def test_post_validate_redirects(self, monkeypatch, pyramid_request,
161163

162164
assert user_service.find_userid.calls == [pretend.call("theuser")]
163165
assert user_service.update_user.calls == [
164-
pretend.call(1, last_login=now),
166+
pretend.call(user_id, last_login=now),
165167
]
166168

167169
if with_user:
168170
assert new_session == {}
169171
else:
170172
assert new_session == {"a": "b", "foo": "bar"}
171173

172-
assert remember.calls == [pretend.call(pyramid_request, 1)]
174+
assert remember.calls == [pretend.call(pyramid_request, str(user_id))]
173175
assert pyramid_request.session.invalidate.calls == [pretend.call()]
174176
assert pyramid_request.find_service.calls == [
175177
pretend.call(IUserService, context=None),

tests/unit/test_csp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ def test_includeme():
205205
],
206206
"referrer": ["origin-when-cross-origin"],
207207
"reflected-xss": ["block"],
208-
"script-src": ["'self'"],
208+
"script-src": ["'self'", "www.google-analytics.com"],
209209
"style-src": ["'self'", "fonts.googleapis.com"],
210210
},
211211
})

warehouse/accounts/models.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
Boolean, DateTime, Integer, String,
1717
)
1818
from sqlalchemy import orm, select, sql
19+
from sqlalchemy.dialects.postgresql import UUID
1920
from sqlalchemy.orm.exc import NoResultFound
2021
from sqlalchemy.ext.hybrid import hybrid_property
2122

@@ -38,7 +39,7 @@ def __getitem__(self, username):
3839
raise KeyError from None
3940

4041

41-
class User(SitemapMixin, db.ModelBase):
42+
class User(SitemapMixin, db.Model):
4243

4344
__tablename__ = "accounts_user"
4445
__table_args__ = (
@@ -51,7 +52,6 @@ class User(SitemapMixin, db.ModelBase):
5152

5253
__repr__ = make_repr("username")
5354

54-
id = Column(Integer, primary_key=True, nullable=False)
5555
username = Column(CIText, nullable=False, unique=True)
5656
name = Column(String(length=100), nullable=False)
5757
password = Column(String(length=128), nullable=False)
@@ -104,12 +104,8 @@ class Email(db.ModelBase):
104104

105105
id = Column(Integer, primary_key=True, nullable=False)
106106
user_id = Column(
107-
Integer,
108-
ForeignKey(
109-
"accounts_user.id",
110-
deferrable=True,
111-
initially="DEFERRED",
112-
),
107+
UUID(as_uuid=True),
108+
ForeignKey("accounts_user.id", deferrable=True, initially="DEFERRED"),
113109
nullable=False,
114110
)
115111
email = Column(String(length=254), nullable=False)

warehouse/accounts/views.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import datetime
1414

15+
from pyblake2 import blake2b
1516
from pyramid.httpexceptions import HTTPMovedPermanently, HTTPSeeOther
1617
from pyramid.security import remember, forget
1718
from pyramid.view import view_config
@@ -25,6 +26,9 @@
2526
from warehouse.utils.http import is_safe_url
2627

2728

29+
USER_ID_INSECURE_COOKIE = "user_id__insecure"
30+
31+
2832
@view_config(
2933
route_name="accounts.profile",
3034
renderer="accounts/profile.html",
@@ -91,7 +95,23 @@ def login(request, redirect_field_name=REDIRECT_FIELD_NAME,
9195

9296
# Now that we're logged in we'll want to redirect the user to either
9397
# where they were trying to go originally, or to the default view.
94-
return HTTPSeeOther(redirect_to, headers=dict(headers))
98+
resp = HTTPSeeOther(redirect_to, headers=dict(headers))
99+
100+
# We'll use this cookie so that client side javascript can Determine
101+
# the actual user ID (not username, user ID). This is *not* a security
102+
# sensitive context and it *MUST* not be used where security matters.
103+
#
104+
# We'll also hash this value just to avoid leaking the actual User IDs
105+
# here, even though it really shouldn't matter.
106+
resp.set_cookie(
107+
USER_ID_INSECURE_COOKIE,
108+
blake2b(
109+
str(userid).encode("ascii"),
110+
person=b"warehouse.userid",
111+
).hexdigest().lower(),
112+
)
113+
114+
return resp
95115

96116
return {
97117
"form": form,
@@ -141,7 +161,13 @@ def logout(request, redirect_field_name=REDIRECT_FIELD_NAME):
141161

142162
# Now that we're logged out we'll want to redirect the user to either
143163
# where they were originally, or to the default view.
144-
return HTTPSeeOther(redirect_to, headers=dict(headers))
164+
resp = HTTPSeeOther(redirect_to, headers=dict(headers))
165+
166+
# Ensure that we delete our user_id__insecure cookie, since the user is
167+
# no longer logged in.
168+
resp.delete_cookie(USER_ID_INSECURE_COOKIE)
169+
170+
return resp
145171

146172
return {"redirect": {"field": REDIRECT_FIELD_NAME, "data": redirect_to}}
147173

@@ -213,7 +239,7 @@ def _login_user(request, userid):
213239
request.session.update(data)
214240

215241
# Remember the userid using the authentication policy.
216-
headers = remember(request, userid)
242+
headers = remember(request, str(userid))
217243

218244
# Cycle the CSRF token since we've crossed an authentication boundary
219245
# and we don't want to continue using the old one.

warehouse/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ def configure(settings=None):
142142
maybe_set(settings, "camo.url", "CAMO_URL")
143143
maybe_set(settings, "camo.key", "CAMO_KEY")
144144
maybe_set(settings, "docs.url", "DOCS_URL")
145+
maybe_set(settings, "ga.tracking_id", "GA_TRACKING_ID")
145146
maybe_set_compound(settings, "files", "backend", "FILES_BACKEND")
146147
maybe_set_compound(settings, "origin_cache", "backend", "ORIGIN_CACHE")
147148

warehouse/csp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def includeme(config):
7070
],
7171
"referrer": ["origin-when-cross-origin"],
7272
"reflected-xss": ["block"],
73-
"script-src": [SELF],
73+
"script-src": [SELF, "www.google-analytics.com"],
7474
"style-src": [SELF, "fonts.googleapis.com"],
7575
},
7676
})

warehouse/legacy/tables.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
UniqueConstraint,
2424
Boolean, Date, DateTime, Integer, LargeBinary, String, Text,
2525
)
26+
from sqlalchemy.dialects.postgresql import UUID
2627

2728
from warehouse import db
2829

@@ -34,12 +35,8 @@
3435
Column("id", Integer(), primary_key=True, nullable=False),
3536
Column(
3637
"user_id",
37-
Integer(),
38-
ForeignKey(
39-
"accounts_user.id",
40-
deferrable=True,
41-
initially="DEFERRED",
42-
),
38+
UUID(as_uuid=True),
39+
ForeignKey("accounts_user.id", deferrable=True, initially="DEFERRED"),
4340
nullable=False,
4441
),
4542
Column("key_id", CIText(), nullable=False),
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
"""
13+
Switch to a UUID based primary key for User
14+
15+
Revision ID: 8c8be2c0e69e
16+
Revises: 039f45e2dbf9
17+
Create Date: 2016-07-01 18:20:42.072664
18+
"""
19+
20+
21+
import sqlalchemy as sa
22+
23+
from alembic import op
24+
from sqlalchemy.dialects import postgresql
25+
26+
27+
revision = "8c8be2c0e69e"
28+
down_revision = "039f45e2dbf9"
29+
30+
31+
def upgrade():
32+
# Add a new column which is going to hold all of our new IDs for this table
33+
# with a temporary name until we can rename it.
34+
op.add_column(
35+
"accounts_user",
36+
sa.Column(
37+
"new_id",
38+
postgresql.UUID(as_uuid=True),
39+
server_default=sa.text("gen_random_uuid()"),
40+
nullable=False,
41+
),
42+
)
43+
44+
# Add a column to tables that refer to accounts_user so they can be updated
45+
# to refer to it.
46+
op.add_column(
47+
"accounts_email",
48+
sa.Column("new_user_id", postgresql.UUID(as_uuid=True), nullable=True),
49+
)
50+
op.add_column(
51+
"accounts_gpgkey",
52+
sa.Column("new_user_id", postgresql.UUID(as_uuid=True), nullable=True),
53+
)
54+
55+
# Update our referring tables so that their new column points to the
56+
# correct user account.
57+
op.execute(
58+
""" UPDATE accounts_email
59+
SET new_user_id = accounts_user.new_id
60+
FROM accounts_user
61+
WHERE accounts_email.user_id = accounts_user.id
62+
"""
63+
)
64+
op.execute(
65+
""" UPDATE accounts_gpgkey
66+
SET new_user_id = accounts_user.new_id
67+
FROM accounts_user
68+
WHERE accounts_gpgkey.user_id = accounts_user.id
69+
"""
70+
)
71+
72+
# Disallow any NULL values in our referring tables
73+
op.alter_column("accounts_email", "new_user_id", nullable=False)
74+
op.alter_column("accounts_gpgkey", "new_user_id", nullable=False)
75+
76+
# Delete our existing fields and move our new fields into their old places.
77+
op.drop_constraint("accounts_email_user_id_fkey", "accounts_email")
78+
op.drop_column("accounts_email", "user_id")
79+
op.alter_column("accounts_email", "new_user_id", new_column_name="user_id")
80+
81+
op.drop_constraint("accounts_gpgkey_user_id_fkey", "accounts_gpgkey")
82+
op.drop_column("accounts_gpgkey", "user_id")
83+
op.alter_column(
84+
"accounts_gpgkey", "new_user_id", new_column_name="user_id")
85+
86+
# Switch the primary key from the old to the new field, drop the old name,
87+
# and rename the new field into it's place.
88+
op.drop_constraint("accounts_user_pkey", "accounts_user")
89+
op.create_primary_key(None, "accounts_user", ["new_id"])
90+
op.drop_column("accounts_user", "id")
91+
op.alter_column("accounts_user", "new_id", new_column_name="id")
92+
93+
# Finally, Setup our foreign key constraints for our referring tables.
94+
op.create_foreign_key(
95+
None,
96+
"accounts_email",
97+
"accounts_user",
98+
["user_id"],
99+
["id"],
100+
deferrable=True,
101+
)
102+
op.create_foreign_key(
103+
None,
104+
"accounts_gpgkey",
105+
"accounts_user",
106+
["user_id"],
107+
["id"],
108+
deferrable=True,
109+
)
110+
111+
112+
def downgrade():
113+
raise RuntimeError("Order No. 227 - Ни шагу назад!")

warehouse/static/js/warehouse/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,17 @@ import "babel-polyfill";
2222
import docReady from "warehouse/utils/doc-ready";
2323

2424
// Import our utility functions
25+
import Analytics from "warehouse/utils/analytics";
2526
import HTMLInclude from "warehouse/utils/html-include";
2627
import * as formUtils from "warehouse/utils/forms";
2728
import Clipboard from "clipboard";
2829

2930
// Kick off the client side HTML includes.
3031
docReady(HTMLInclude);
3132

33+
// Trigger our analytics code.
34+
docReady(Analytics);
35+
3236
// Handle the JS based automatic form submission.
3337
docReady(formUtils.submitTriggers);
3438

0 commit comments

Comments
 (0)