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] 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 ###