Add device sharing functionality

develop
Ensar Sarajčić 2018-11-01 01:45:14 +01:00
parent c66414ca85
commit 641a5eca46
9 changed files with 239 additions and 6 deletions

View File

@ -23,6 +23,9 @@ class Account(db.Model):
default=db.func.current_timestamp(), default=db.func.current_timestamp(),
onupdate=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): def __init__(self, username, password, email, role=2):
self.username = str(username) self.username = str(username)
self.password = str(password) self.password = str(password)

View File

@ -23,7 +23,9 @@ def add_resources():
DeviceTypeResource, DeviceTypeResource,
DeviceTypeListResource, DeviceTypeListResource,
DeviceConfigurationResource, DeviceConfigurationResource,
DeviceSecretResource) DeviceSecretResource,
DeviceShareResource,
DeviceShareActivationResource)
from .resources.dashboard import (DashboardResource, from .resources.dashboard import (DashboardResource,
DashboardListResource, DashboardListResource,
DashboardWidgetResource, DashboardWidgetResource,
@ -53,6 +55,11 @@ def add_resources():
'/v1/devices/<int:device_id>/configuration') '/v1/devices/<int:device_id>/configuration')
api.add_resource(DeviceSecretResource, api.add_resource(DeviceSecretResource,
'/v1/devices/<int:device_id>/secret') '/v1/devices/<int:device_id>/secret')
api.add_resource(DeviceShareResource,
'/v1/devices/<int:device_id>/share')
api.add_resource(
DeviceShareActivationResource,
'/v1/devices/<int:device_id>/share/activate/<string:token>')
api.add_resource(DashboardResource, api.add_resource(DashboardResource,
'/v1/dashboards/<int:dashboard_id>') '/v1/dashboards/<int:dashboard_id>')
api.add_resource(DashboardListResource, '/v1/dashboards') api.add_resource(DashboardListResource, '/v1/dashboards')

View File

@ -2,10 +2,12 @@ from flask_restful import abort
from marshmallow import Schema, fields from marshmallow import Schema, fields
from webargs.flaskparser import use_args from webargs.flaskparser import use_args
from flasgger import swag_from 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 import app.devices.api as devices
from app.api.auth_protection import ProtectedResource from app.api.auth_protection import ProtectedResource
from app.api.schemas import BaseResourceSchema from app.api.schemas import BaseResourceSchema
from flask import current_app as app
class BasicDeviceTypeSchema(Schema): class BasicDeviceTypeSchema(Schema):
@ -46,6 +48,16 @@ class DeviceSecretSchema(BaseResourceSchema):
secret_algorithm = fields.String() 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): def validate_device_ownership(device_id):
if not devices.can_user_access_device(g.current_account.id, 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', abort(403, message='You are not allowed to access this device',
@ -158,3 +170,36 @@ class DeviceSecretResource(ProtectedResource):
def get(self, device_id): def get(self, device_id):
validate_device_ownership(device_id) validate_device_ownership(device_id)
return DeviceSecretSchema().dump(devices.get_device(device_id)), 200 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')

View File

@ -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'

View File

@ -122,8 +122,10 @@ class DashboardWidget(db.Model):
__tablename__ = 'dashboard_widgets' __tablename__ = 'dashboard_widgets'
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
dashboard_id = db.Column(db.Integer, db.ForeignKey('dashboards.id')) dashboard_id = db.Column(db.Integer, db.ForeignKey('dashboards.id'),
device_id = db.Column(db.Integer, db.ForeignKey('devices.id')) nullable=False)
device_id = db.Column(db.Integer, db.ForeignKey('devices.id'),
nullable=False)
height = db.Column(db.Integer, nullable=False) height = db.Column(db.Integer, nullable=False)
width = db.Column(db.Integer, nullable=False) width = db.Column(db.Integer, nullable=False)
x = db.Column(db.Integer, nullable=False) x = db.Column(db.Integer, nullable=False)

View File

@ -1,7 +1,12 @@
import sys import sys
import hmac import hmac
import urllib.parse 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.core import app
from app.jsonql import api as jsonql from app.jsonql import api as jsonql
@ -263,8 +268,69 @@ def create_recording(device_id, raw_json):
recording.save() 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): def run_custom_query(device_id, request):
""" """
Runs custom query as defined by jsonql module
""" """
if not Device.exists(id=device_id): if not Device.exists(id=device_id):
raise ValueError("Device does not exist!") raise ValueError("Device does not exist!")

View File

@ -226,6 +226,9 @@ class DeviceAssociation(db.Model):
access_level = db.Column(db.Integer, db.ForeignKey('access_levels.id'), access_level = db.Column(db.Integer, db.ForeignKey('access_levels.id'),
nullable=False) nullable=False)
access_level_data = db.relationship("AccessLevel",
foreign_keys=[access_level])
def __init__(self, device_id, account_id, access_level=1): def __init__(self, device_id, account_id, access_level=1):
self.device_id = device_id self.device_id = device_id
self.account_id = account_id self.account_id = account_id
@ -329,9 +332,20 @@ class AccessLevel(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String, nullable=False) 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.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): def __repr__(self):
return '<AccessLevel (name %s)>' % self.name return '<AccessLevel (name %s)>' % self.name

View File

@ -227,6 +227,31 @@ definitions:
device_type: device_type:
$ref: '#/definitions/DeviceType' $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: DeviceWithConfig:
type: object type: object
required: required:

View File

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