From 5e13fcac5d6032f1ea4d0645c4d80771f106dfbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ensar=20Saraj=C4=8Di=C4=87?= Date: Wed, 31 Oct 2018 21:43:27 +0100 Subject: [PATCH 1/6] Add redirection to frontend on email activation --- app/accounts/api.py | 6 +++--- app/api/resources/account.py | 14 ++++++++++---- app/templates/welcome_to_iot.html | 3 +++ config.py | 4 ++++ 4 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 app/templates/welcome_to_iot.html diff --git a/app/accounts/api.py b/app/accounts/api.py index b1a1d57..76cf236 100644 --- a/app/accounts/api.py +++ b/app/accounts/api.py @@ -33,15 +33,15 @@ def confirm_email_token(token): try: email = confirm_token(token) except Exception: - return False + return False, None user = Account.query.filter_by(email=email).first_or_404() if user.confirmed: - return True + return True, user.email else: user.confirmed = True user.confirmed_on = datetime.datetime.now() user.save() - return True + return True, user.email def update_account_role(account_id, role_id): diff --git a/app/api/resources/account.py b/app/api/resources/account.py index 1eae2ea..9cead0d 100644 --- a/app/api/resources/account.py +++ b/app/api/resources/account.py @@ -1,5 +1,5 @@ from flask_restful import Resource, abort -from flask import g, render_template +from flask import g, render_template, redirect from marshmallow import Schema, fields from webargs.flaskparser import use_args from flasgger import swag_from @@ -10,6 +10,7 @@ from app.api.auth_protection import ProtectedResource from app.api.permission_protection import (requires_permission, valid_permissions) from app.api.schemas import BaseResourceSchema +from flask import current_app as app class UserSchema(BaseResourceSchema): @@ -107,10 +108,15 @@ class AccountListResource(Resource): class AccountEmailTokenResource(Resource): def get(self, token): - success = accounts.confirm_email_token(token) + success, email = accounts.confirm_email_token(token) if success: - return '{"status": "success", \ - "message": "Successfully confirmed email"}', 200 + html = render_template( + 'welcome_to_iot.html') + send_email_task.delay( + email, + 'Welcome to IoT!', + html) + return redirect(app.config['FRONTEND_URL']) class AccountEmailTokenResendResource(Resource): diff --git a/app/templates/welcome_to_iot.html b/app/templates/welcome_to_iot.html new file mode 100644 index 0000000..0acc6c1 --- /dev/null +++ b/app/templates/welcome_to_iot.html @@ -0,0 +1,3 @@ +

Welcome! Thanks for signing up! You have successfully confirmed your email.

+
+

Cheers!

diff --git a/config.py b/config.py index 890b690..2a1f3ba 100644 --- a/config.py +++ b/config.py @@ -55,6 +55,10 @@ MAIL_PASSWORD = os.environ['APP_MAIL_PASSWORD'] # mail accounts MAIL_DEFAULT_SENDER = 'final.iot.backend.mailer@gmail.com' +# frontend +FRONTEND_URL = (os.environ.get('IOT_FRONTEND_URL') or + 'http://iot-frontend-app.herokuapp.com/') + # Flasgger config SWAGGER = { 'uiversion': 3 From 6381ae13283227c141e93174f1dd6bd872704dc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ensar=20Saraj=C4=8Di=C4=87?= Date: Wed, 31 Oct 2018 21:54:59 +0100 Subject: [PATCH 2/6] Delete related widgets when deleting devices --- app/devices/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/devices/models.py b/app/devices/models.py index 87919b8..5a9d73c 100644 --- a/app/devices/models.py +++ b/app/devices/models.py @@ -135,6 +135,8 @@ class Device(db.Model): cascade="save-update, merge, delete") recordings = db.relationship("Recording", cascade="save-update, merge, delete") + widgets = db.relationship("DashboardWidget", + cascade="save-update, merge, delete") def __init__(self, name, configuration=None, device_type=1): self.name = name From c66414ca85311bf9332de6615ba6dc5a2bce644e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ensar=20Saraj=C4=8Di=C4=87?= Date: Wed, 31 Oct 2018 21:59:21 +0100 Subject: [PATCH 3/6] Allow secrets to be changed through environment --- config.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/config.py b/config.py index 2a1f3ba..609bf1b 100644 --- a/config.py +++ b/config.py @@ -23,11 +23,13 @@ CSRF_ENABLED = True # Use a secure, unique and absolutely secret key for # signing the data. -CSRF_SESSION_KEY = "secret" +CSRF_SESSION_KEY = os.environ.get('CSRF_SECRET') or "secret" # Secret key for signing cookies -SECRET_KEY = "?['Z(Z\x83Y \x06T\x12\x96<\xff\x12\xe0\x1b\xd1J\xe0\xd9ld" -SECURITY_PASSWORD_SALT = "IyoZvOJb4feT3xKlYXyOJveHSIY4GDg6" +SECRET_KEY = (os.environ.get('APP_SECRET_KEY') or + "?['Z(Z\x83Y\x06T\x12\x96<\xff\x12\xe0\x1b\xd1J\xe0\xd9ld") +SECURITY_PASSWORD_SALT = (os.environ.get('APP_SECRETS_SALT') or + "IyoZvOJb4feT3xKlYXyOJveHSIY4GDg6") # MQTT configuration MQTT_CLIENT_ID = 'final-iot-backend-server-' + os.environ['MQTT_CLIENT'] From af6025fec7eb9de854ad8b8e71d56fd7283ed0d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ensar=20Saraj=C4=8Di=C4=87?= Date: Wed, 31 Oct 2018 23:25:54 +0100 Subject: [PATCH 4/6] Improve email templates --- app/api/resources/account.py | 10 +- app/templates/activate_mail.html | 391 +++++++++++++++++++++++++++++- app/templates/welcome_to_iot.html | 385 ++++++++++++++++++++++++++++- 3 files changed, 775 insertions(+), 11 deletions(-) diff --git a/app/api/resources/account.py b/app/api/resources/account.py index 9cead0d..b36a49a 100644 --- a/app/api/resources/account.py +++ b/app/api/resources/account.py @@ -99,7 +99,7 @@ class AccountListResource(Resource): confirm_url=confirm_url) send_email_task.delay( args['email'], - 'Please confirm your email', + 'ETF IoT Email confirmation', html) return UserSchema().dump(created_account), 201 except ValueError: @@ -110,13 +110,15 @@ class AccountEmailTokenResource(Resource): def get(self, token): success, email = accounts.confirm_email_token(token) if success: + frontend_url = app.config['FRONTEND_URL'] html = render_template( - 'welcome_to_iot.html') + 'welcome_to_iot.html', + frontend_url=frontend_url) send_email_task.delay( email, - 'Welcome to IoT!', + 'Welcome to ETF IoT!', html) - return redirect(app.config['FRONTEND_URL']) + return redirect(frontend_url) class AccountEmailTokenResendResource(Resource): diff --git a/app/templates/activate_mail.html b/app/templates/activate_mail.html index 8634c07..3fad75a 100644 --- a/app/templates/activate_mail.html +++ b/app/templates/activate_mail.html @@ -1,4 +1,387 @@ -

Welcome! Thanks for signing up. Please follow this link to activate your account:

-

{{ confirm_url }}

-
-

Cheers!

+ + + + + + Please activate your email for ETF IoT + + + + + + + + + + + + diff --git a/app/templates/welcome_to_iot.html b/app/templates/welcome_to_iot.html index 0acc6c1..476cf80 100644 --- a/app/templates/welcome_to_iot.html +++ b/app/templates/welcome_to_iot.html @@ -1,3 +1,382 @@ -

Welcome! Thanks for signing up! You have successfully confirmed your email.

-
-

Cheers!

+ + + + + + Welcome to IoT! + + + + + + + + + + + + From 641a5eca46a4486cdc6f6826379bb0032de06df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ensar=20Saraj=C4=8Di=C4=87?= Date: Thu, 1 Nov 2018 01:45:14 +0100 Subject: [PATCH 5/6] Add device sharing functionality --- app/accounts/models.py | 3 + app/api/blueprint.py | 9 ++- app/api/resources/device.py | 47 ++++++++++++- .../create_device_share_token_spec.yaml | 21 ++++++ app/dashboards/models.py | 6 +- app/devices/api.py | 68 ++++++++++++++++++- app/devices/models.py | 16 ++++- app/swagger/template.yaml | 25 +++++++ migrations/versions/43e5ad1c4393_.py | 50 ++++++++++++++ 9 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 app/api/resources/swagger/create_device_share_token_spec.yaml create mode 100644 migrations/versions/43e5ad1c4393_.py diff --git a/app/accounts/models.py b/app/accounts/models.py index 8a47fc3..d205226 100644 --- a/app/accounts/models.py +++ b/app/accounts/models.py @@ -23,6 +23,9 @@ class Account(db.Model): default=db.func.current_timestamp(), onupdate=db.func.current_timestamp()) + dashboards = db.relationship("Dashboard", + cascade="save-update, merge, delete") + def __init__(self, username, password, email, role=2): self.username = str(username) self.password = str(password) diff --git a/app/api/blueprint.py b/app/api/blueprint.py index b5236cd..5dc5918 100644 --- a/app/api/blueprint.py +++ b/app/api/blueprint.py @@ -23,7 +23,9 @@ def add_resources(): DeviceTypeResource, DeviceTypeListResource, DeviceConfigurationResource, - DeviceSecretResource) + DeviceSecretResource, + DeviceShareResource, + DeviceShareActivationResource) from .resources.dashboard import (DashboardResource, DashboardListResource, DashboardWidgetResource, @@ -53,6 +55,11 @@ def add_resources(): '/v1/devices//configuration') api.add_resource(DeviceSecretResource, '/v1/devices//secret') + api.add_resource(DeviceShareResource, + '/v1/devices//share') + api.add_resource( + DeviceShareActivationResource, + '/v1/devices//share/activate/') api.add_resource(DashboardResource, '/v1/dashboards/') api.add_resource(DashboardListResource, '/v1/dashboards') diff --git a/app/api/resources/device.py b/app/api/resources/device.py index 7780ae1..711cb6a 100644 --- a/app/api/resources/device.py +++ b/app/api/resources/device.py @@ -2,10 +2,12 @@ from flask_restful import abort from marshmallow import Schema, fields from webargs.flaskparser import use_args from flasgger import swag_from -from flask import g, request +from flask import g, request, redirect +from app.api.blueprint import api import app.devices.api as devices from app.api.auth_protection import ProtectedResource from app.api.schemas import BaseResourceSchema +from flask import current_app as app class BasicDeviceTypeSchema(Schema): @@ -46,6 +48,16 @@ class DeviceSecretSchema(BaseResourceSchema): secret_algorithm = fields.String() +class DeviceShareSchema(BaseResourceSchema): + access_level_id = fields.Integer() + account_id = fields.Integer(required=False) + + +class DeviceShareTokenSchema(BaseResourceSchema): + token = fields.String() + activation_url = fields.String() + + def validate_device_ownership(device_id): if not devices.can_user_access_device(g.current_account.id, device_id): abort(403, message='You are not allowed to access this device', @@ -158,3 +170,36 @@ class DeviceSecretResource(ProtectedResource): def get(self, device_id): validate_device_ownership(device_id) return DeviceSecretSchema().dump(devices.get_device(device_id)), 200 + + +class DeviceShareResource(ProtectedResource): + @use_args(DeviceShareSchema(), locations=('json',)) + @swag_from('swagger/create_device_share_token_spec.yaml') + def post(self, args, device_id): + validate_device_ownership(device_id) + created_token = devices.create_targeted_device_sharing_token( + device_id, args['access_level_id'], args.get('account_id')) + activation_url = api.url_for( + DeviceShareActivationResource, + device_id=device_id, + token=created_token, _external=True) + return DeviceShareTokenSchema().dump( + { + 'token': created_token, + 'activation_url': activation_url + } + ), 201 + + +class DeviceShareActivationResource(ProtectedResource): + def get(self, device_id, token): + try: + success = devices.activate_device_sharing_token( + g.current_account.id, token) + if not success: + abort(403, + message='You may not get access to this device', + status='error') + return redirect(app.config['FRONTEND_URL']) + except ValueError as e: + abort(400, message=str(e), status='error') diff --git a/app/api/resources/swagger/create_device_share_token_spec.yaml b/app/api/resources/swagger/create_device_share_token_spec.yaml new file mode 100644 index 0000000..f2971bf --- /dev/null +++ b/app/api/resources/swagger/create_device_share_token_spec.yaml @@ -0,0 +1,21 @@ +Creates new device sharing token +--- +tags: + - Device +parameters: + - in: body + name: body + required: true + schema: + type: object + $ref: '#/definitions/DeviceShareTokenCreation' +responses: + 201: + description: Success + schema: + type: object + required: + - content + properties: + content: + $ref: '#/definitions/DeviceShareToken' diff --git a/app/dashboards/models.py b/app/dashboards/models.py index acd049a..091052f 100644 --- a/app/dashboards/models.py +++ b/app/dashboards/models.py @@ -122,8 +122,10 @@ class DashboardWidget(db.Model): __tablename__ = 'dashboard_widgets' id = db.Column(db.Integer, primary_key=True, autoincrement=True) - dashboard_id = db.Column(db.Integer, db.ForeignKey('dashboards.id')) - device_id = db.Column(db.Integer, db.ForeignKey('devices.id')) + dashboard_id = db.Column(db.Integer, db.ForeignKey('dashboards.id'), + nullable=False) + device_id = db.Column(db.Integer, db.ForeignKey('devices.id'), + nullable=False) height = db.Column(db.Integer, nullable=False) width = db.Column(db.Integer, nullable=False) x = db.Column(db.Integer, nullable=False) diff --git a/app/devices/api.py b/app/devices/api.py index 3c63856..514de4b 100644 --- a/app/devices/api.py +++ b/app/devices/api.py @@ -1,7 +1,12 @@ import sys import hmac import urllib.parse -from .models import Device, Recording, DeviceAssociation, DeviceType +from .models import (Device, + Recording, + DeviceAssociation, + DeviceType, + AccessLevel) +from itsdangerous import URLSafeSerializer from app.core import app from app.jsonql import api as jsonql @@ -263,8 +268,69 @@ def create_recording(device_id, raw_json): recording.save() +def create_targeted_device_sharing_token( + device_id, access_level_id, account_id=None): + """ + Creates device sharing token that can be passed only to account with passed + id in order to allow access to device + + :param device_id: Id of device + :type device_id: int + :param access_level_id: Id of access level this link will give + :type access_level_id: int + :param account_id: Id of account + :type account_id: int + :raises: ValueError if device does not exist + """ + if not Device.exists(id=device_id): + raise ValueError("Device does not exist!") + if not AccessLevel.exists(id=access_level_id): + raise ValueError("AccessLevel does not exist!") + + data_to_serialize = { + 'device_id': device_id, + 'access_level_id': access_level_id + } + + if account_id is not None: + data_to_serialize['account_id'] = account_id + + serializer = URLSafeSerializer(app.config['SECRET_KEY'], + salt=app.config['SECURITY_PASSWORD_SALT']) + token = serializer.dumps(data_to_serialize) + return token + + +def activate_device_sharing_token(account_id, token): + """ + Activates device sharing token for account with passed id + + :param account_id: Id of account + :type account_id: int + :param token: Token created by device owner + :type token: string + :raises: ValueError if device does not exist + """ + serializer = URLSafeSerializer(app.config['SECRET_KEY'], + salt=app.config['SECURITY_PASSWORD_SALT']) + token_data = serializer.loads(token) + device_id = token_data['device_id'] + access_level_id = token_data['access_level_id'] + if (token_data.get('account_id') or account_id) != account_id: + return False + + if not Device.exists(id=device_id): + raise ValueError("Device does not exist!") + + device_association = DeviceAssociation(device_id, account_id, + access_level_id) + device_association.save() + return True + + def run_custom_query(device_id, request): """ + Runs custom query as defined by jsonql module """ if not Device.exists(id=device_id): raise ValueError("Device does not exist!") diff --git a/app/devices/models.py b/app/devices/models.py index 5a9d73c..e21d9ed 100644 --- a/app/devices/models.py +++ b/app/devices/models.py @@ -226,6 +226,9 @@ class DeviceAssociation(db.Model): access_level = db.Column(db.Integer, db.ForeignKey('access_levels.id'), nullable=False) + access_level_data = db.relationship("AccessLevel", + foreign_keys=[access_level]) + def __init__(self, device_id, account_id, access_level=1): self.device_id = device_id self.account_id = account_id @@ -329,9 +332,20 @@ class AccessLevel(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) name = db.Column(db.String, nullable=False) + permissions = db.Column(db.ARRAY(db.String), nullable=False) - def __init__(self, name): + def __init__(self, name, permissions=['VIEW_DEVICE']): self.name = name + self.permissions = permissions + + @staticmethod + def exists(**kwargs): + """ + Checks if access level with all of the given arguments exists + """ + if AccessLevel.query.filter_by(**kwargs).first(): + return True + return False def __repr__(self): return '' % self.name diff --git a/app/swagger/template.yaml b/app/swagger/template.yaml index 46a83ef..f45f263 100644 --- a/app/swagger/template.yaml +++ b/app/swagger/template.yaml @@ -227,6 +227,31 @@ definitions: device_type: $ref: '#/definitions/DeviceType' + DeviceShareTokenCreation: + type: object + required: + - access_level_id + properties: + access_level_id: + $ref: '#/definitions/id' + account_id: + $ref: '#/definitions/id' + + DeviceShareToken: + type: object + required: + - token + - activation_url + properties: + token: + type: string + description: Activation token used to gain access to shared device + example: idjsfodsfmskdf12312nkVDSFSDFS + activation_url: + type: string + description: Activation url using token + example: https://etf-iot.com/api/v1/devices/123/share/activation/idjsfodsfmskdf12312nkVDSFSDFS + DeviceWithConfig: type: object required: diff --git a/migrations/versions/43e5ad1c4393_.py b/migrations/versions/43e5ad1c4393_.py new file mode 100644 index 0000000..003409b --- /dev/null +++ b/migrations/versions/43e5ad1c4393_.py @@ -0,0 +1,50 @@ +"""empty message + +Revision ID: 43e5ad1c4393 +Revises: c09252c2c547 +Create Date: 2018-11-01 00:58:07.570743 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '43e5ad1c4393' +down_revision = 'c09252c2c547' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('access_levels', sa.Column('permissions', + sa.ARRAY(sa.String()), nullable=True)) + + access_levels = sa.table('access_levels', sa.column('id', sa.Integer), + sa.column('permissions', sa.ARRAY(sa.String))) + op.execute(access_levels.update().where(access_levels.c.id == op.inline_literal(1)). + values({'permissions': + ['VIEW_DEVICE', 'MODIFY_DEVICE', + 'DELETE_DEVICE', 'WIDGET_READ', + 'WIDGET_WRITE', 'CONFIGURATION_READ', + 'CONFIGURATION_WRITE', 'SECRET_READ']}) + ) + + op.alter_column('access_levels', 'permissions', + existing_type=sa.ARRAY(sa.String()), + nullable=False) + + op.alter_column('dashboard_widgets', 'device_id', + existing_type=sa.INTEGER(), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('dashboard_widgets', 'device_id', + existing_type=sa.INTEGER(), + nullable=True) + op.drop_column('access_levels', 'permissions') + # ### end Alembic commands ### From fc9379d4a41715f160762d503fb48bd6c6076495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ensar=20Saraj=C4=8Di=C4=87?= Date: Thu, 1 Nov 2018 01:46:17 +0100 Subject: [PATCH 6/6] Version 0.4.0 --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.py b/config.py index 609bf1b..acf60d4 100644 --- a/config.py +++ b/config.py @@ -2,7 +2,7 @@ import os # App configuration DEBUG = os.environ['DEBUG'] -APP_VERSION = '0.3.6' +APP_VERSION = '0.4.0' # Define the application directory BASE_DIR = os.path.abspath(os.path.dirname(__file__))