Skip to content

Commit

Permalink
ToS acceptation
Browse files Browse the repository at this point in the history
  • Loading branch information
lbesson committed Mar 6, 2023
1 parent a77a211 commit 1e34cfc
Show file tree
Hide file tree
Showing 8 changed files with 108 additions and 18 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ In another terminal (`docker-compose up` must be running) :
./scripts/test.sh c2corg_api/tests/models/test_book.py::TestBook::test_to_archive
```

Note: if you're using MinGW on Windows, be sure to prefix the command with `MSYS_PATH_NOCONV=1`
Note: if you're using MinGW on Windows, be sure to prefix the command with `MSYS_NO_PATHCONV=1`

## Useful links in [wiki](https://github.com/c2corg/v6_api/wiki)

Expand Down
4 changes: 2 additions & 2 deletions alembic_migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ model.

Create the migration script with:

```
```bash
docker-compose exec api .build/venv/bin/alembic revision -m 'Add column x'
```

Expand All @@ -26,6 +26,6 @@ is used.

A migration should be run each time the application code is updated or if you have just created a migration script.

```
```bash
docker-compose exec api .build/venv/bin/alembic upgrade head
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Add column for terms of service
Revision ID: 1d851410e3af
Revises: 305b064bdf66
Create Date: 2023-03-03 17:29:38.587079
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '1d851410e3af'
down_revision = '305b064bdf66'
branch_labels = None
depends_on = None

def upgrade():
op.add_column(
'user',
sa.Column('tos_validated', sa.DateTime(timezone=True), nullable=True),
schema='users'
)


def downgrade():
op.drop_column('user', 'tos_validated', schema='users')
2 changes: 2 additions & 0 deletions c2corg_api/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ class User(Base):
DateTime(timezone=True), default=func.now(), onupdate=func.now(),
nullable=False, index=True)
blocked = Column(Boolean, nullable=False, default=False)
tos_validated = Column(
DateTime(timezone=True), nullable=True, unique=False)

lang = Column(
String(2), ForeignKey(schema + '.langs.lang'),
Expand Down
23 changes: 20 additions & 3 deletions c2corg_api/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ def _add_global_test_data(session):
name='Contributor',
username='contributor', email='[email protected]',
forum_username='contributor', password='super pass',
email_validated=True, profile=contributor_profile)
tos_validated=datetime.datetime(2020, 12, 28), email_validated=True,
profile=contributor_profile)

contributor2_profile = UserProfile(
categories=['amateur'],
Expand All @@ -94,6 +95,7 @@ def _add_global_test_data(session):
username='contributor2', email='[email protected]',
forum_username='contributor2',
password='better pass', email_validated=True,
tos_validated=datetime.datetime(2021, 2, 12),
profile=contributor2_profile)

contributor3_profile = UserProfile(
Expand All @@ -105,8 +107,20 @@ def _add_global_test_data(session):
username='contributor3', email='[email protected]',
forum_username='contributor3',
password='poor pass', email_validated=True,
tos_validated=datetime.datetime(2006, 1, 1),
profile=contributor3_profile)

contributor_notos_profile = UserProfile(
categories=['amateur'],
locales=[DocumentLocale(title='...', lang='en')])

contributor_notos = User(
name='Contributor no ToS',
username='contributornotos', email='[email protected]',
forum_username='contributornotos',
password='some pass', email_validated=True,
profile=contributor_notos_profile)

moderator_profile = UserProfile(
categories=['mountain_guide'],
locales=[DocumentLocale(title='', lang='en')])
Expand All @@ -116,6 +130,7 @@ def _add_global_test_data(session):
username='moderator', email='[email protected]',
forum_username='moderator',
moderator=True, password='even better pass',
tos_validated=datetime.datetime(2021, 2, 12),
email_validated=True, profile=moderator_profile)

robot_profile = UserProfile(
Expand All @@ -126,9 +141,11 @@ def _add_global_test_data(session):
username='robot', email='[email protected]',
forum_username='robot',
robot=True, password='bombproof pass',
tos_validated=datetime.datetime(2021, 6, 6),
email_validated=True, profile=robot_profile)

users = [robot, moderator, contributor, contributor2, contributor3]
users = [robot, moderator, contributor, contributor2,
contributor3, contributor_notos]
session.add_all(users)
session.flush()

Expand All @@ -155,7 +172,7 @@ def _add_global_test_data(session):
now = datetime.datetime.utcnow()
exp = now + datetime.timedelta(weeks=10)

for user in [robot, moderator, contributor, contributor2, contributor3]:
for user in users:
claims = create_claims(user, exp)
token = jwt.encode(claims, key=key, algorithm=algorithm). \
decode('utf-8')
Expand Down
21 changes: 19 additions & 2 deletions c2corg_api/tests/views/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
from urllib.parse import urlparse

import re
import time
import datetime

from unittest.mock import Mock, MagicMock, patch

Expand Down Expand Up @@ -116,6 +118,7 @@ def test_always_register_non_validated_users(self, _send_email):
user_id = body.get('id')
user = self.session.query(User).get(user_id)
self.assertFalse(user.email_validated)
self.assertIsNotNone(user.tos_validated)
_send_email.check_call_once()

@patch('c2corg_api.emails.email_service.EmailService._send_email')
Expand Down Expand Up @@ -506,7 +509,7 @@ def test_purge_tokens(self):
self.assertEqual(0, query.count())

def login(self, username, password=None, status=200, sso=None, sig=None,
discourse=None):
discourse=None, accept_tos=None):
if not password:
password = self.global_passwords[username]

Expand All @@ -521,6 +524,8 @@ def login(self, username, password=None, status=200, sso=None, sig=None,
request_body['sig'] = sig
if discourse:
request_body['discourse'] = discourse
if accept_tos:
request_body['accept_tos'] = accept_tos

url = '/users/login'
response = self.app_post_json(url, request_body, status=status)
Expand Down Expand Up @@ -572,8 +577,20 @@ def test_login_failure(self):
body = self.login('moderator', password='invalid', status=403).json
self.assertEqual(body['status'], 'error')

def test_login_no_tos_failure(self):
body = self.login('contributornotos', password='some pass', status=403).json
self.assertErrorsContain(body, 'Forbidden', 'TOS not accepted')

def test_login_no_tos_success(self):
# A user which did not previously accepted ToS can login
# if he accepts them. It is stored in the db.
body = self.login('contributornotos', password='some pass', accept_tos=True, status=200).json
self.assertTrue('token' in body)
user = self.session.query(User).filter(
User.username == 'contributornotos').one()
self.assertTrue((datetime.datetime.now(datetime.timezone.utc) - user.tos_validated).total_seconds() < 5)

def assertExpireAlmostEqual(self, expire, days, seconds_delta): # noqa
import time
now = int(round(time.time()))
expected = days * 24 * 3600 + now # 14 days from now
if (abs(expected - expire) > seconds_delta):
Expand Down
19 changes: 10 additions & 9 deletions c2corg_api/tests/views/test_user_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,21 @@ def test_get_collection_paginated(self):
self.assertResultsEqual(
self.get_collection(
{'offset': 0, 'limit': 0}, user='contributor'),
[], 7)
[], 8)

self.assertResultsEqual(
self.get_collection(
{'offset': 0, 'limit': 1}, user='contributor'),
[self.profile4.document_id], 7)
[self.profile4.document_id], 8)
self.assertResultsEqual(
self.get_collection(
{'offset': 0, 'limit': 2}, user='contributor'),
[self.profile4.document_id, self.profile2.document_id], 7)
[self.profile4.document_id, self.profile2.document_id], 8)
self.assertResultsEqual(
self.get_collection(
{'offset': 1, 'limit': 3}, user='contributor'),
[self.profile2.document_id, self.global_userids['contributor3'],
self.global_userids['contributor2']], 7)
[self.profile2.document_id, self.global_userids['contributornotos'],
self.global_userids['contributor3']], 8)

def test_get_collection_lang(self):
self.get_collection_lang(user='contributor')
Expand All @@ -65,10 +65,11 @@ def test_get_collection_search(self):

self.assertResultsEqual(
self.get_collection_search({'l': 'en'}, user='contributor'),
[self.profile4.document_id, self.global_userids['contributor3'],
self.global_userids['contributor2'], self.profile1.document_id,
self.global_userids['moderator'], self.global_userids['robot']],
6)
[self.profile4.document_id, self.global_userids['contributornotos'],
self.global_userids['contributor3'], self.global_userids['contributor2'],
self.profile1.document_id, self.global_userids['moderator'],
self.global_userids['robot']],
7)

def test_get_unauthenticated_private_profile(self):
"""Tests that only the user name is returned when requesting a private
Expand Down
28 changes: 27 additions & 1 deletion c2corg_api/views/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,16 @@ def post(self):
Purpose.registration,
VALIDATION_EXPIRE_DAYS)

# directly create the user profile, the document id of the profile
# Directly create the user profile, the document id of the profile
# is the user id
lang = user.lang
user.profile = UserProfile(
categories=['amateur'],
locales=[DocumentLocale(lang=lang, title='')]
)
# Checkbox is mandatory on the frontend when registering
# so we can store ToS acceptance.
user.tos_validated = datetime.datetime.utcnow()

DBSession.add(user)
try:
Expand Down Expand Up @@ -453,6 +456,7 @@ def post(self):
class LoginSchema(colander.MappingSchema):
username = colander.SchemaNode(colander.String())
password = colander.SchemaNode(colander.String())
accept_tos = colander.SchemaNode(colander.Boolean(), missing=False)


login_schema = LoginSchema()
Expand Down Expand Up @@ -486,15 +490,37 @@ def post(self):
request = self.request
username = request.validated['username']
password = request.validated['password']
accept_tos = request.validated['accept_tos']
user = DBSession.query(User). \
filter(User.username == username).first()

# try to use the username as email if we didn't find the user
if user is None and is_valid_email(username):
user = DBSession.query(User). \
filter(User.email == username).first()

token = try_login(user, password, request) if user else None
if token:
# Check if the user has validated Terms of Service, if not,
# return a 403 with an explicit message that can be caught
# by the frontend
if user.tos_validated is None and accept_tos is not True:
raise HTTPForbidden('TOS not accepted')

# If the user has not validated Terms of Service, but the request
# sends the accept field, store it in the database
if user.tos_validated is None and accept_tos is True:
try:
DBSession.execute(
User.__table__.update().
where(User.id == user.id).
values(tos_validated=datetime.datetime.utcnow())
)
DBSession.flush()
except Exception:
log.warning('Error persisting user', exc_info=True)
raise HTTPInternalServerError('Error persisting user')

response = token_to_response(user, token, request)
if 'discourse' in request.json:
settings = request.registry.settings
Expand Down

0 comments on commit 1e34cfc

Please sign in to comment.