Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ToS acceptation #1542

Merged
merged 6 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: 626354ffcda0
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 = '626354ffcda0'
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
26 changes: 24 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,25 @@ def test_login_failure(self):
body = self.login('moderator', password='invalid', status=401).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',
'Terms of Service need to be 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
23 changes: 14 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,22 @@ 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 +66,14 @@ 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('Terms of Service need to be 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
Loading