Skip to content

Commit 5b02a8a

Browse files
authored
Is787/invitation update (#834)
Improvements on #787 - invitation **gets deleted upon ONE use or expiration (5 days)** - moved portal demo scripts from ``services/web/server/tests/sandbox`` ==> [scripts/demo](scripts/demo) - create demo markdown and csv file with invitation codes - bit of cleanup refactoring in login subsytem - updated tests - doc and logs - Aiohttp server Background task loop deprecated
1 parent fa0b880 commit 5b02a8a

File tree

9 files changed

+152
-113
lines changed

9 files changed

+152
-113
lines changed

scripts/demo/portal_markdown.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,3 @@ This pages is for testing purposes for issue [#715](https://github.com/ITISFound
5151
- [weedI0YvR6tMA7XEpaxgJZT2Z8SCUy](https://staging.osparc.io/#/registration/?invitation=weedI0YvR6tMA7XEpaxgJZT2Z8SCUy)
5252
- [Q9m5C98ALYZDr1BjilkaaXWSMKxU21](https://staging.osparc.io/#/registration/?invitation=Q9m5C98ALYZDr1BjilkaaXWSMKxU21)
5353
- [jvhSQfoAAfin4htKgvvRYi3pkYdPhM](https://staging.osparc.io/#/registration/?invitation=jvhSQfoAAfin4htKgvvRYi3pkYdPhM)
54-

services/web/server/src/simcore_service_webserver/login/__init__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88

99
import asyncpg
1010
from aiohttp import web
11+
1112
from servicelib.application_keys import APP_CONFIG_KEY
1213

1314
from ..db import DSN
1415
from ..db_config import CONFIG_SECTION_NAME as DB_SECTION
1516
from ..email_config import CONFIG_SECTION_NAME as SMTP_SECTION
17+
from ..rest_config import CONFIG_SECTION_NAME as REST_SECTION
1618
from ..rest_config import APP_OPENAPI_SPECS_KEY
1719
from ..statics import INDEX_RESOURCE_NAME
1820
from .cfg import APP_LOGIN_CONFIG, cfg
@@ -37,7 +39,7 @@ async def _setup_config_and_pgpool(app: web.Application):
3739
db_cfg = app[APP_CONFIG_KEY][DB_SECTION]['postgres']
3840

3941
# db
40-
pool = await asyncpg.create_pool(dsn=DSN.format(**db_cfg), loop=app.loop)
42+
pool = await asyncpg.create_pool(dsn=DSN.format(**db_cfg), loop=asyncio.get_event_loop())
4143
storage = AsyncpgStorage(pool) #NOTE: this key belongs to cfg, not settings!
4244

4345
# config
@@ -64,7 +66,7 @@ async def _setup_config_and_pgpool(app: web.Application):
6466
yield
6567

6668
try:
67-
await asyncio.wait_for( pool.close(), timeout=TIMEOUT_SECS, loop=app.loop)
69+
await asyncio.wait_for( pool.close(), timeout=TIMEOUT_SECS)
6870
except asyncio.TimeoutError:
6971
log.exception("Failed to close login storage loop")
7072

@@ -79,7 +81,7 @@ def setup(app: web.Application):
7981

8082
log.debug("Setting up %s ...", __name__)
8183

82-
# TODO: requires rest ready!
84+
assert REST_SECTION in app[APP_CONFIG_KEY]
8385
assert SMTP_SECTION in app[APP_CONFIG_KEY]
8486
assert DB_SECTION in app[APP_CONFIG_KEY]
8587

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
""" Confirmation codes/tokens tools
2+
3+
Codes are inserted in confirmation tables and they are associated to a user and an action
4+
Used to validate some action (e.g. register, invitation, etc)
5+
Codes can be used one time
6+
Codes have expiration date (duration time is configurable)
7+
"""
8+
import logging
9+
from datetime import datetime, timedelta
10+
11+
from ..db_models import ConfirmationAction
12+
from .cfg import cfg
13+
14+
log = logging.getLogger(__name__)
15+
16+
async def validate_confirmation_code(code, db):
17+
confirmation = await db.get_confirmation({'code': code})
18+
if confirmation and is_confirmation_expired(confirmation):
19+
log.info("Confirmation code '%s' %s. Deleting ...", code,
20+
"consumed" if confirmation else "expired")
21+
await db.delete_confirmation(confirmation)
22+
confirmation = None
23+
return confirmation
24+
25+
26+
async def make_confirmation_link(request, confirmation):
27+
link = request.app.router['auth_confirmation'].url_for(code=confirmation['code'])
28+
return '{}://{}{}'.format(request.scheme, request.host, link)
29+
30+
31+
def get_expiration_date(confirmation):
32+
lifetime = get_confirmation_lifetime(confirmation)
33+
estimated_expiration = confirmation['created_at'] + lifetime
34+
return estimated_expiration
35+
36+
37+
async def is_confirmation_allowed(user, action):
38+
db = cfg.STORAGE
39+
confirmation = await db.get_confirmation({'user': user, 'action': action})
40+
if not confirmation:
41+
return True
42+
if is_confirmation_expired(confirmation):
43+
await db.delete_confirmation(confirmation)
44+
return True
45+
46+
47+
def is_confirmation_expired(confirmation):
48+
age = datetime.utcnow() - confirmation['created_at']
49+
lifetime = get_confirmation_lifetime(confirmation)
50+
return age > lifetime
51+
52+
53+
def get_confirmation_lifetime(confirmation):
54+
lifetime_days = cfg['{}_CONFIRMATION_LIFETIME'.format(
55+
confirmation['action'].upper())]
56+
lifetime = timedelta(days=lifetime_days)
57+
return lifetime
58+
59+
60+
__all__ = (
61+
"ConfirmationAction",
62+
)

services/web/server/src/simcore_service_webserver/login/handlers.py

Lines changed: 4 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
11
import logging
22

3-
import attr
43
import passwordmeter
54
from aiohttp import web
65
from yarl import URL
76

8-
from servicelib.rest_models import LogMessageType
97
from servicelib.rest_utils import extract_and_validate
108

119
from ..db_models import ConfirmationAction, UserRole, UserStatus
1210
from ..security_api import check_password, encrypt_password, forget, remember
1311
from .cfg import APP_LOGIN_CONFIG, cfg, get_storage
1412
from .config import get_login_config
13+
from .confirmation import (is_confirmation_allowed, make_confirmation_link,
14+
validate_confirmation_code)
1515
from .decorators import RQT_USERID_KEY, login_required
16-
from .storage import AsyncpgStorage
17-
from .utils import (common_themed, get_client_ip, is_confirmation_allowed,
18-
is_confirmation_expired, make_confirmation_link,
16+
from .registration import check_invitation, check_registration
17+
from .utils import (common_themed, flash_response, get_client_ip,
1918
render_and_send_mail, themed)
2019

2120
# FIXME: do not use cfg singleton. use instead cfg = request.app[APP_LOGIN_CONFIG]
@@ -410,62 +409,3 @@ async def check_password_strength(request: web.Request):
410409
if improvements:
411410
data['improvements'] = improvements
412411
return data
413-
414-
415-
416-
# helpers -----------------------------------------------------------------
417-
async def check_invitation(invitation:str, db):
418-
confirmation = await validate_confirmation_code(invitation, db)
419-
if confirmation is None:
420-
raise web.HTTPForbidden(reason="Request requires invitation or invitation expired")
421-
422-
423-
async def validate_confirmation_code(code, db):
424-
confirmation = await db.get_confirmation({'code': code})
425-
if confirmation and is_confirmation_expired(confirmation):
426-
await db.delete_confirmation(confirmation)
427-
confirmation = None
428-
return confirmation
429-
430-
431-
def flash_response(msg: str, level: str="INFO"):
432-
response = web.json_response(data={
433-
'data': attr.asdict(LogMessageType(msg, level)),
434-
'error': None
435-
})
436-
return response
437-
438-
439-
async def check_registration(email: str, password: str, confirm: str, db: AsyncpgStorage):
440-
# email : required & formats
441-
# password: required & secure[min length, ...]
442-
443-
# If the email field is missing, return a 400 - HTTPBadRequest
444-
if email is None or password is None:
445-
raise web.HTTPBadRequest(reason="Email and password required",
446-
content_type='application/json')
447-
448-
if confirm and password != confirm:
449-
raise web.HTTPConflict(reason=cfg.MSG_PASSWORD_MISMATCH,
450-
content_type='application/json')
451-
452-
# TODO: If the email field isn’t a valid email, return a 422 - HTTPUnprocessableEntity
453-
# TODO: If the password field is too short, return a 422 - HTTPUnprocessableEntity
454-
# TODO: use passwordmeter to enforce good passwords, but first create helper in front-end
455-
456-
user = await db.get_user({'email': email})
457-
if user:
458-
# Resets pending confirmation if re-registers?
459-
if user['status'] == CONFIRMATION_PENDING:
460-
_confirmation = await db.get_confirmation({'user': user, 'action': REGISTRATION})
461-
462-
if is_confirmation_expired(_confirmation):
463-
await db.delete_confirmation(_confirmation)
464-
await db.delete_user(user)
465-
return
466-
467-
# If the email is already taken, return a 409 - HTTPConflict
468-
raise web.HTTPConflict(reason=cfg.MSG_EMAIL_EXISTS,
469-
content_type='application/json')
470-
471-
log.debug("Registration data validated")

services/web/server/src/simcore_service_webserver/login/registration.py

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,63 @@
11
""" Core functionality and tools for user's registration
22
3-
3+
- registration code
4+
- invitation code
45
"""
5-
# TODO: Move handlers.check_registration and other utils related with registration here
66
import json
77
import logging
8+
from pprint import pformat
9+
from typing import Dict
810

11+
from aiohttp import web
912
from yarl import URL
1013

11-
from ..db_models import ConfirmationAction
14+
from ..db_models import UserStatus
15+
from .cfg import cfg
16+
from .confirmation import (ConfirmationAction, get_expiration_date,
17+
is_confirmation_expired, validate_confirmation_code)
1218
from .storage import AsyncpgStorage
13-
from .utils import get_expiration_date
1419

1520
log = logging.getLogger(__name__)
1621

17-
async def create_invitation(host, guest, db:AsyncpgStorage):
22+
23+
async def check_registration(email: str, password: str, confirm: str, db: AsyncpgStorage):
24+
# email : required & formats
25+
# password: required & secure[min length, ...]
26+
27+
# If the email field is missing, return a 400 - HTTPBadRequest
28+
if email is None or password is None:
29+
raise web.HTTPBadRequest(reason="Email and password required",
30+
content_type='application/json')
31+
32+
if confirm and password != confirm:
33+
raise web.HTTPConflict(reason=cfg.MSG_PASSWORD_MISMATCH,
34+
content_type='application/json')
35+
36+
# TODO: If the email field isn’t a valid email, return a 422 - HTTPUnprocessableEntity
37+
# TODO: If the password field is too short, return a 422 - HTTPUnprocessableEntity
38+
# TODO: use passwordmeter to enforce good passwords, but first create helper in front-end
39+
40+
user = await db.get_user({'email': email})
41+
if user:
42+
# Resets pending confirmation if re-registers?
43+
if user['status'] == UserStatus.CONFIRMATION_PENDING.value:
44+
_confirmation = await db.get_confirmation({
45+
'user': user,
46+
'action': ConfirmationAction.REGISTRATION.value
47+
})
48+
49+
if is_confirmation_expired(_confirmation):
50+
await db.delete_confirmation(_confirmation)
51+
await db.delete_user(user)
52+
return
53+
54+
# If the email is already taken, return a 409 - HTTPConflict
55+
raise web.HTTPConflict(reason=cfg.MSG_EMAIL_EXISTS,
56+
content_type='application/json')
57+
58+
log.debug("Registration data validated")
59+
60+
async def create_invitation(host:Dict, guest:str, db:AsyncpgStorage):
1861
""" Creates an invitation token for a guest to register in the platform
1962
2063
Creates and injects an invitation token in the confirmation table associated
@@ -34,10 +77,22 @@ async def create_invitation(host, guest, db:AsyncpgStorage):
3477
)
3578
return confirmation
3679

80+
async def check_invitation(invitation:str, db):
81+
confirmation = None
82+
if invitation:
83+
confirmation = await validate_confirmation_code(invitation, db)
3784

38-
def get_confirmation_info(confirmation):
39-
info = confirmation
85+
if confirmation:
86+
#FIXME: check if action=invitation??
87+
log.info("Invitation code used. Deleting %s", pformat(get_confirmation_info(confirmation)))
88+
await db.delete_confirmation(confirmation)
89+
else:
90+
raise web.HTTPForbidden(reason=("Invalid invitation code."
91+
"Your invitation was already used or might have expired."
92+
"Please contact our support team to get a new one.") )
4093

94+
def get_confirmation_info(confirmation):
95+
info = dict(confirmation)
4196
# data column is a string
4297
try:
4398
info['data'] = json.loads(confirmation['data'])
@@ -52,7 +107,6 @@ def get_confirmation_info(confirmation):
52107

53108
return info
54109

55-
56110
def get_invitation_url(confirmation, origin: URL=None) -> URL:
57111
code = confirmation['code']
58112
assert confirmation['action'] == ConfirmationAction.INVITATION.name

services/web/server/src/simcore_service_webserver/login/utils.py

Lines changed: 12 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import random
22
import string
3-
from datetime import datetime, timedelta
3+
44
from email.mime.text import MIMEText
55
from logging import getLogger
66
from os.path import join
77

88
import aiosmtplib
9+
import attr
910
import passlib.hash
11+
from aiohttp import web
1012
from aiohttp_jinja2 import render_string
1113

14+
from servicelib.rest_models import LogMessageType
15+
1216
from ..resources import resources
1317
from .cfg import cfg # TODO: remove this singleton!!!
1418

@@ -33,40 +37,6 @@ def get_random_string(min_len, max_len=None):
3337
return ''.join(random.choice(CHARS) for x in range(size))
3438

3539

36-
async def make_confirmation_link(request, confirmation):
37-
link = request.app.router['auth_confirmation'].url_for(code=confirmation['code'])
38-
return '{}://{}{}'.format(request.scheme, request.host, link)
39-
40-
41-
async def is_confirmation_allowed(user, action):
42-
db = cfg.STORAGE
43-
confirmation = await db.get_confirmation({'user': user, 'action': action})
44-
if not confirmation:
45-
return True
46-
if is_confirmation_expired(confirmation):
47-
await db.delete_confirmation(confirmation)
48-
return True
49-
50-
51-
def is_confirmation_expired(confirmation):
52-
age = datetime.utcnow() - confirmation['created_at']
53-
lifetime = get_confirmation_lifetime(confirmation)
54-
return age > lifetime
55-
56-
57-
def get_confirmation_lifetime(confirmation):
58-
lifetime_days = cfg['{}_CONFIRMATION_LIFETIME'.format(
59-
confirmation['action'].upper())]
60-
lifetime = timedelta(days=lifetime_days)
61-
return lifetime
62-
63-
64-
def get_expiration_date(confirmation):
65-
lifetime = get_confirmation_lifetime(confirmation)
66-
estimated_expiration = confirmation['created_at'] + lifetime
67-
return estimated_expiration
68-
69-
7040
def get_client_ip(request):
7141
try:
7242
ips = request.headers['X-Forwarded-For']
@@ -118,3 +88,10 @@ def themed(template):
11888

11989
def common_themed(template):
12090
return resources.get_path(join(cfg.COMMON_THEME, template))
91+
92+
def flash_response(msg: str, level: str="INFO"):
93+
response = web.json_response(data={
94+
'data': attr.asdict(LogMessageType(msg, level)),
95+
'error': None
96+
})
97+
return response

services/web/server/src/simcore_service_webserver/storage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
async def storage_client_ctx(app: web.Application):
1919
# TODO: deduce base url from configuration and add to session
20-
async with ClientSession(loop=app.loop) as session: # TODO: check if should keep webserver->storage session?
20+
async with ClientSession() as session: # TODO: check if should keep webserver->storage session?
2121
app[APP_STORAGE_SESSION_KEY] = session
2222
yield
2323

services/web/server/tests/helpers/utils_login.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,5 @@ async def __aenter__(self):
104104
return self.confirmation
105105

106106
async def __aexit__(self, *args):
107-
await self.db.delete_confirmation(self.confirmation)
107+
if await self.db.get_confirmation(self.confirmation):
108+
await self.db.delete_confirmation(self.confirmation)

services/web/server/tests/unit/with_postgres/test_registration.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ async def test_registration_with_invitation(client, is_invitation_required, has_
153153
})
154154
await assert_status(r, expected_response)
155155

156+
if is_invitation_required and has_valid_invitation:
157+
db = get_storage(client.app)
158+
assert not await db.get_confirmation(confirmation)
159+
156160

157161
if __name__ == '__main__':
158162
pytest.main([__file__, '--maxfail=1'])

0 commit comments

Comments
 (0)