Skip to content

Commit eebbfe5

Browse files
authored
Merge pull request #22 from cul-it/accounts-0.2
Pre-release merge for accounts-0.2
2 parents 2501eb3 + 58f2ea4 commit eebbfe5

File tree

12 files changed

+239
-1533
lines changed

12 files changed

+239
-1533
lines changed

‎Pipfile.lock‎

Lines changed: 0 additions & 806 deletions
This file was deleted.

‎accounts/Pipfile‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ sqlalchemy = "*"
1818
uwsgi = "*"
1919
wtforms = "*"
2020
arxiv-base = "==0.10.1rc2"
21-
arxiv-auth = "==0.1.0rc11"
21+
authlib = "*"
22+
redis-py-cluster = "*"
23+
arxiv-auth = "==0.1.0rc12"
2224

2325
[dev-packages]
2426
mimesis = "*"
25-
mypy = "==0.610"
27+
mypy = "*"
2628
pydocstyle = "*"
2729
pylint = "*"
2830
sphinx = "*"
@@ -31,6 +33,7 @@ coverage = "*"
3133
coveralls = "*"
3234
pytest = "*"
3335
pytest-cov = "*"
36+
"nose2" = "*"
3437

3538
[requires]
3639
python_version = "3.6"

‎accounts/Pipfile.lock‎

Lines changed: 0 additions & 719 deletions
This file was deleted.

‎accounts/accounts/controllers/authentication.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from werkzeug import MultiDict, ImmutableMultiDict
1717
from werkzeug.exceptions import BadRequest, InternalServerError
18-
from flask import url_for
18+
from flask import url_for, Markup
1919

2020
from wtforms import StringField, PasswordField, SelectField, \
2121
SelectMultipleField, BooleanField, Form, HiddenField
@@ -88,6 +88,16 @@ def login(method: str, form_data: MultiDict, ip: str,
8888
data.update({'error': 'Invalid username or password.'})
8989
return data, status.HTTP_400_BAD_REQUEST, {}
9090

91+
if not userdata.verified:
92+
data.update({
93+
'error': Markup(
94+
'Your account has not yet been verified. Please contact '
95+
'<a href="mailto:help@arxiv.org">help@arxiv.org</a> if '
96+
'you believe this to be in error.'
97+
)
98+
})
99+
return data, status.HTTP_400_BAD_REQUEST, {}
100+
91101
# Create a session in the distributed session store.
92102
try:
93103
session = sessions.create(auths, ip, ip, track, user=userdata)

‎accounts/accounts/controllers/tests/test_authentication.py‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ def test_post_great(self, mock_legacy, mock_sessions, mock_users):
119119
user = domain.User(
120120
user_id=42,
121121
username='foouser',
122-
email='user@ema.il'
122+
email='user@ema.il',
123+
verified=True
123124
)
124125
auths = domain.Authorizations(
125126
classic=6,
@@ -156,3 +157,54 @@ def test_post_great(self, mock_legacy, mock_sessions, mock_users):
156157
"Session cookie is returned")
157158
self.assertEqual(data['cookies']['classic_cookie'], (c_cookie, None),
158159
"Classic session cookie is returned")
160+
161+
@mock.patch('accounts.controllers.authentication.users')
162+
@mock.patch('accounts.controllers.authentication.sessions')
163+
@mock.patch('accounts.controllers.authentication.legacy')
164+
def test_post_not_verified(self, mock_legacy, mock_sessions, mock_users):
165+
"""Form data are valid and check out."""
166+
mock_users.exceptions.AuthenticationFailed = \
167+
users.exceptions.AuthenticationFailed
168+
mock_sessions.exceptions.SessionCreationFailed = \
169+
sessions.exceptions.SessionCreationFailed
170+
mock_legacy.exceptions.SessionCreationFailed = \
171+
legacy.exceptions.SessionCreationFailed
172+
form_data = MultiDict({'username': 'foouser', 'password': 'bazpass'})
173+
ip = '123.45.67.89'
174+
next_page = '/foo'
175+
start_time = datetime.now(tz=EASTERN)
176+
user = domain.User(
177+
user_id=42,
178+
username='foouser',
179+
email='user@ema.il',
180+
verified=False
181+
)
182+
auths = domain.Authorizations(
183+
classic=6,
184+
scopes=['public:read', 'submission:create']
185+
)
186+
mock_users.authenticate.return_value = user, auths
187+
c_session = domain.Session(
188+
session_id='barsession',
189+
user=user,
190+
start_time=start_time,
191+
authorizations=auths
192+
)
193+
c_cookie = 'bardata'
194+
mock_legacy.create.return_value = c_session
195+
mock_legacy.generate_cookie.return_value = c_cookie
196+
session = domain.Session(
197+
session_id='foosession',
198+
user=user,
199+
start_time=start_time,
200+
authorizations=domain.Authorizations(
201+
scopes=['public:read', 'submission:create']
202+
)
203+
)
204+
cookie = 'foodata'
205+
mock_sessions.create.return_value = session
206+
mock_sessions.generate_cookie.return_value = cookie
207+
208+
data, status_code, header = login('POST', form_data, ip, next_page)
209+
self.assertEqual(status_code, status.HTTP_400_BAD_REQUEST,
210+
"Bad request error is returned")

‎accounts/accounts/routes/ui.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,22 @@ def set_cookies(response: Response, data: dict) -> None:
6767
**params)
6868

6969

70+
# This is unlikely to be useful once the classic submission UI is disabled.
71+
def unset_submission_cookie(response: Response) -> None:
72+
"""
73+
Unset the legacy Catalyst submission cookie.
74+
75+
In addition to the authenticated session (which was originally from the
76+
Tapir auth system), Catalyst also tracks a session used specifically for
77+
the submission process. The legacy Catalyst controller sets this
78+
automatically, so we don't need to do anything on login. But on logout,
79+
if this cookie is not cleared, Catalyst may attempt to use the same
80+
submission session upon subsequent logins. This can lead to weird
81+
inconsistencies.
82+
"""
83+
response.set_cookie('submit_session', '', max_age=0, httponly=True)
84+
85+
7086
# @blueprint.route('/register', methods=['GET', 'POST'])
7187
@anonymous_only
7288
def register() -> Response:
@@ -133,6 +149,7 @@ def login() -> Response:
133149
# Set the session cookie.
134150
response = make_response(redirect(headers.get('Location'), code=code))
135151
set_cookies(response, data)
152+
unset_submission_cookie(response) # Fix for ARXIVNG-1149.
136153
return response
137154

138155
# Form is invalid, or login failed.
@@ -156,6 +173,7 @@ def logout() -> Response:
156173
logger.debug('Redirecting to %s: %i', headers.get('Location'), code)
157174
response = make_response(redirect(headers.get('Location'), code=code))
158175
set_cookies(response, data)
176+
unset_submission_cookie(response) # Fix for ARXIVNG-1149.
159177
return response
160178
return redirect(url_for('get_login'), code=status.HTTP_302_FOUND)
161179

‎accounts/accounts/tests/test_end_to_end.py‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,3 +447,133 @@ def test_login_logout(self):
447447
'0',
448448
'Classic session cookie is expired'
449449
)
450+
451+
452+
class TestLogoutLegacySubmitCookie(TestCase):
453+
"""The legacy system has a submission session cookie that must be unset."""
454+
455+
@classmethod
456+
def setUpClass(self):
457+
"""Spin up redis."""
458+
# self.redis = subprocess.run(
459+
# "docker run -d -p 7000:7000 -p 7001:7001 -p 7002:7002 -p 7003:7003"
460+
# " -p 7004:7004 -p 7005:7005 -p 7006:7006 -e \"IP=0.0.0.0\""
461+
# " --hostname=server grokzen/redis-cluster:4.0.9",
462+
# stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
463+
# )
464+
# time.sleep(10) # In case it takes a moment to start.
465+
# if self.redis.returncode > 0:
466+
# raise RuntimeError('Could not start redis. Is Docker running?')
467+
#
468+
# self.container = self.redis.stdout.decode('ascii').strip()
469+
self.secret = 'bazsecret'
470+
self.db = 'db.sqlite'
471+
try:
472+
self.app = create_web_app()
473+
self.app.config['CLASSIC_COOKIE_NAME'] = 'foo_tapir_session'
474+
self.app.config['SESSION_COOKIE_NAME'] = 'baz_session'
475+
self.app.config['SESSION_COOKIE_SECURE'] = '0'
476+
self.app.config['JWT_SECRET'] = self.secret
477+
self.app.config['CLASSIC_DATABASE_URI'] = f'sqlite:///{self.db}'
478+
self.app.config['REDIS_HOST'] = 'localhost'
479+
self.app.config['REDIS_PORT'] = '7000'
480+
481+
with self.app.app_context():
482+
from accounts.services import legacy, users
483+
legacy.create_all()
484+
users.create_all()
485+
486+
with users.transaction() as session:
487+
# We have a good old-fashioned user.
488+
db_user = users.models.DBUser(
489+
user_id=1,
490+
first_name='first',
491+
last_name='last',
492+
suffix_name='iv',
493+
email='first@last.iv',
494+
policy_class=2,
495+
flag_edit_users=1,
496+
flag_email_verified=1,
497+
flag_edit_system=0,
498+
flag_approved=1,
499+
flag_deleted=0,
500+
flag_banned=0,
501+
tracking_cookie='foocookie',
502+
)
503+
db_nick = users.models.DBUserNickname(
504+
nick_id=1,
505+
nickname='foouser',
506+
user_id=1,
507+
user_seq=1,
508+
flag_valid=1,
509+
role=0,
510+
policy=0,
511+
flag_primary=1
512+
)
513+
salt = b'fdoo'
514+
password = b'thepassword'
515+
hashed = hashlib.sha1(salt + b'-' + password).digest()
516+
encrypted = b64encode(salt + hashed)
517+
db_password = users.models.DBUserPassword(
518+
user_id=1,
519+
password_storage=2,
520+
password_enc=encrypted
521+
)
522+
session.add(db_user)
523+
session.add(db_password)
524+
session.add(db_nick)
525+
526+
except Exception as e:
527+
stop_container(self.container)
528+
raise
529+
530+
@classmethod
531+
def tearDownClass(self):
532+
"""Tear down redis and the test DB."""
533+
# stop_container(self.container)
534+
os.remove(self.db)
535+
536+
def test_logout_clears_legacy_submit_cookie(self):
537+
"""When the user logs out, the legacy submit cookie is unset."""
538+
client = self.app.test_client()
539+
# client.set_cookie('localhost')
540+
form_data = {'username': 'foouser', 'password': 'thepassword'}
541+
542+
# Werkzeug should keep the cookies around for the next request.
543+
response = client.post('/login', data=form_data)
544+
cookies = _parse_cookies(response.headers.getlist('Set-Cookie'))
545+
546+
client.set_cookie('', 'submit_session', '12345678')
547+
self.assertIn(self.app.config['SESSION_COOKIE_NAME'], cookies,
548+
"Sets cookie for authn session.")
549+
self.assertIn(self.app.config['CLASSIC_COOKIE_NAME'], cookies,
550+
"Sets cookie for classic sessions.")
551+
552+
response = client.get('/logout')
553+
logout_cookies = _parse_cookies(response.headers.getlist('Set-Cookie'))
554+
555+
self.assertEqual(
556+
logout_cookies[self.app.config['SESSION_COOKIE_NAME']]['value'],
557+
'',
558+
'Session cookie is unset'
559+
)
560+
self.assertEqual(
561+
logout_cookies[self.app.config['SESSION_COOKIE_NAME']]['Max-Age'],
562+
'0',
563+
'Session cookie is expired'
564+
)
565+
self.assertEqual(
566+
logout_cookies[self.app.config['CLASSIC_COOKIE_NAME']]['value'],
567+
'',
568+
'Classic cookie is unset'
569+
)
570+
self.assertEqual(
571+
logout_cookies[self.app.config['CLASSIC_COOKIE_NAME']]['Max-Age'],
572+
'0',
573+
'Classic session cookie is expired'
574+
)
575+
self.assertEqual(
576+
logout_cookies['submit_session']['Max-Age'],
577+
'0',
578+
'Legacy submission cookie is expired'
579+
)

‎users/arxiv/users/domain.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,21 @@ class User(NamedTuple):
240240
profile: Optional[UserProfile] = None
241241
"""The user's account profile (if available)."""
242242

243+
verified: bool = False
244+
"""Whether or not the users' e-mail address has been verified."""
245+
246+
# TODO: consider whether this information is relevant beyond the
247+
# ``arxiv.users.legacy.authenticate`` module.
248+
#
249+
# approved: bool = True
250+
# """Whether or not the users' account is approved."""
251+
#
252+
# banned: bool = False
253+
# """Whether or not the user has been banned."""
254+
#
255+
# deleted: bool = False
256+
# """Whether or not the user has been deleted."""
257+
243258

244259
class Client(NamedTuple):
245260
"""API client."""

‎users/arxiv/users/legacy/authenticate.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ def authenticate(username_or_email: Optional[str]=None,
7373
forename=db_user.first_name,
7474
surname=db_user.last_name,
7575
suffix=db_user.suffix_name
76-
)
76+
),
77+
verified=bool(db_user.flag_email_verified)
7778
)
7879
auths = domain.Authorizations(
7980
classic=util.compute_capabilities(db_user),

‎users/arxiv/users/legacy/sessions.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ def load(cookie: str) -> domain.Session:
9797
forename=db_user.first_name,
9898
surname=db_user.last_name,
9999
suffix=db_user.suffix_name
100-
)
100+
),
101+
verified=bool(db_user.flag_email_verified)
101102
)
102103

103104
# We should get one row per endorsement.

0 commit comments

Comments
 (0)