Merged in feature/registration-confirmation (pull request #8)

Feature/registration confirmation
develop
Ensar Sarajcic 2018-10-10 19:08:12 +00:00
commit 1a9cb3f973
16 changed files with 214 additions and 12 deletions

3
.env
View File

@ -3,3 +3,6 @@ REDIS_URL=redis://localhost:6379
CELERY_TASK_SERIALIZER=json CELERY_TASK_SERIALIZER=json
DEBUG=True DEBUG=True
MQTT_CLIENT=local MQTT_CLIENT=local
APP_MAIL_USERNAME=final.iot.backend.mailer@gmail.com
APP_MAIL_PASSWORD=FinalIoT1234
FLASK_ENV=development

View File

@ -1,5 +1,7 @@
import datetime
from app.core import bcrypt from app.core import bcrypt
from .models import Account, Role from .models import Account, Role
from .emailtoken import generate_confirmation_token, confirm_token
def create_account(username, email, password): def create_account(username, email, password):
@ -12,19 +14,36 @@ def create_account(username, email, password):
:type username: string :type username: string
:type email: string :type email: string
:type password: string :type password: string
:returns: True if account is successfully created :returns: Email confirmation token if creation was successful
:rtype: Boolean :rtype: string
:raises: ValueError if account already exists :raises: ValueError if account already exists
""" """
if not Account.exists_with_any_of(username=username, email=email): if not Account.exists_with_any_of(username=username, email=email):
pw_hash = bcrypt.generate_password_hash(password).decode('utf-8') pw_hash = bcrypt.generate_password_hash(password).decode('utf-8')
account = Account(username, pw_hash, email) account = Account(username, pw_hash, email)
account.save() account.save()
return True
emailtoken = generate_confirmation_token(account.email)
return emailtoken
raise ValueError("Account with given parameters already exists") raise ValueError("Account with given parameters already exists")
def confirm_email_token(token):
try:
email = confirm_token(token)
except Exception:
return False
user = Account.query.filter_by(email=email).first_or_404()
if user.confirmed:
return True
else:
user.confirmed = True
user.confirmed_on = datetime.datetime.now()
user.save()
return True
def update_account_role(account_id, role_id): def update_account_role(account_id, role_id):
""" """
Tries to update account role Tries to update account role
@ -99,6 +118,10 @@ def create_token(username, password):
if not bcrypt.check_password_hash(account.password, password): if not bcrypt.check_password_hash(account.password, password):
raise ValueError("Invalid credentials") raise ValueError("Invalid credentials")
if not account.confirmed:
print('ACCOUNT NOT CONFIRMED?')
raise ValueError("Email not confirmed")
return account.create_auth_token() return account.create_auth_token()

View File

@ -0,0 +1,10 @@
from flask_mail import Message
def send_email(mailmanager, to, subject, template):
msg = Message(
subject,
recipients=[to],
html=template
)
mailmanager.send(msg)

View File

@ -0,0 +1,21 @@
from itsdangerous import URLSafeTimedSerializer
from app.core import app
def generate_confirmation_token(email):
serializer = URLSafeTimedSerializer(app.config['SECRET_KEY'])
return serializer.dumps(email, salt=app.config['SECURITY_PASSWORD_SALT'])
def confirm_token(token, expiration=3600):
serializer = URLSafeTimedSerializer(app.config['SECRET_KEY'])
try:
email = serializer.loads(
token,
salt=app.config['SECURITY_PASSWORD_SALT'],
max_age=expiration
)
except Exception:
return False
return email

View File

@ -13,6 +13,8 @@ class Account(db.Model):
email = db.Column(db.String, index=True, unique=True) email = db.Column(db.String, index=True, unique=True)
role_id = db.Column(db.Integer, db.ForeignKey("roles.id")) role_id = db.Column(db.Integer, db.ForeignKey("roles.id"))
role = db.relationship("Role", foreign_keys=[role_id]) role = db.relationship("Role", foreign_keys=[role_id])
confirmed = db.Column(db.Boolean, default=False, nullable=False)
confirmed_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, created_at = db.Column(db.DateTime,
nullable=False, nullable=False,
default=db.func.current_timestamp()) default=db.func.current_timestamp())

View File

@ -0,0 +1,10 @@
from app.celery_builder import task_builder
from app.accounts.email import send_email
from flask import current_app as app
from flask_mail import Mail
@task_builder.task()
def send_email_task(to, subject, template):
mailmanager = Mail(app)
send_email(mailmanager, to, subject, template)

View File

@ -12,7 +12,9 @@ def add_resources():
AccountListResource, AccountListResource,
AccountRoleResource, AccountRoleResource,
RoleResource, RoleResource,
RolesResource) RolesResource,
AccountEmailTokenResource,
AccountEmailTokenResendResource)
from .resources.token import TokenResource, ValidateTokenResource from .resources.token import TokenResource, ValidateTokenResource
from .resources.device import (DeviceResource, from .resources.device import (DeviceResource,
DeviceRecordingResource, DeviceRecordingResource,
@ -25,6 +27,10 @@ def add_resources():
api.add_resource(AccountResource, '/v1/accounts/<int:account_id>') api.add_resource(AccountResource, '/v1/accounts/<int:account_id>')
api.add_resource(AccountListResource, '/v1/accounts') api.add_resource(AccountListResource, '/v1/accounts')
api.add_resource(AccountRoleResource, '/v1/accounts/<int:account_id>/role') api.add_resource(AccountRoleResource, '/v1/accounts/<int:account_id>/role')
api.add_resource(AccountEmailTokenResource,
'/v1/email/confirm/<string:token>')
api.add_resource(AccountEmailTokenResendResource,
'/v1/email/resend')
api.add_resource(RoleResource, '/v1/roles/<int:role_id>') api.add_resource(RoleResource, '/v1/roles/<int:role_id>')
api.add_resource(RolesResource, '/v1/roles') api.add_resource(RolesResource, '/v1/roles')
api.add_resource(TokenResource, '/v1/token') api.add_resource(TokenResource, '/v1/token')

View File

@ -1,9 +1,11 @@
from flask_restful import Resource, abort from flask_restful import Resource, abort
from flask import g from flask import g, render_template
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 app.api.blueprint import api
import app.accounts.api as accounts import app.accounts.api as accounts
from app.accounts.tasks import send_email_task
from app.api.auth_protection import ProtectedResource from app.api.auth_protection import ProtectedResource
from app.api.permission_protection import (requires_permission, from app.api.permission_protection import (requires_permission,
valid_permissions) valid_permissions)
@ -85,11 +87,34 @@ class AccountListResource(Resource):
@swag_from('swagger/create_account_spec.yaml') @swag_from('swagger/create_account_spec.yaml')
def post(self, args): def post(self, args):
try: try:
success = accounts.create_account( emailtoken = accounts.create_account(
args['username'], args['username'],
args['email'], args['email'],
args['password']) args['password'])
if success: confirm_url = api.url_for(
AccountEmailTokenResource,
token=emailtoken, _external=True)
html = render_template(
'activate_mail.html',
confirm_url=confirm_url)
send_email_task.delay(
args['email'],
'Please confirm your email',
html)
return '', 201 return '', 201
except ValueError: except ValueError:
abort(422, message='Account already exists', status='error') abort(422, message='Account already exists', status='error')
class AccountEmailTokenResource(Resource):
def get(self, token):
success = accounts.confirm_email_token(token)
if success:
return '{"status": "success", \
"message": "Successfully confirmed email"}', 200
class AccountEmailTokenResendResource(Resource):
@use_args(UserSchema(), locations=('json',))
def post(self, args):
return '', 201

View File

@ -23,8 +23,8 @@ class TokenResource(Resource):
args['password']) args['password'])
if token: if token:
return {'status': 'success', 'token': token}, 200 return {'status': 'success', 'token': token}, 200
except ValueError: except ValueError, e:
abort(401, message='Invalid credentials', status='error') abort(401, message=str(e), status='error')
class ValidateTokenResource(ProtectedResource): class ValidateTokenResource(ProtectedResource):

View File

@ -1,10 +1,10 @@
# App initialization # App initialization
from flask import Flask from flask import Flask
from .tasks.celery_configurator import make_celery from app.tasks.celery_configurator import make_celery
app = Flask(__name__, instance_relative_config=True) app = Flask(__name__, instance_relative_config=True)
app.config.from_object('config') app.config.from_object('config')
app.config.from_pyfile('config.py', silent=True) app.config.from_pyfile('config.py', silent=True)
app.config['MQTT_CLIENT_ID'] = app.config['MQTT_CLIENT_ID'] + '-worker' app.config['MQTT_CLIENT_ID'] = app.config['MQTT_CLIENT_ID'] + '-worker'
task_builder = make_celery(app) task_builder = make_celery(app)
task_builder.autodiscover_tasks(['app.devices']) task_builder.autodiscover_tasks(['app.devices', 'app.accounts'])

View File

@ -2,6 +2,7 @@
from flask_api import FlaskAPI from flask_api import FlaskAPI
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt from flask_bcrypt import Bcrypt
from flask_mail import Mail
from flasgger import Swagger from flasgger import Swagger
from flask_cors import CORS from flask_cors import CORS
from .tasks import celery_configurator from .tasks import celery_configurator
@ -11,6 +12,7 @@ app.config.from_object('config')
app.config.from_pyfile('config.py', silent=True) app.config.from_pyfile('config.py', silent=True)
db = SQLAlchemy(app) db = SQLAlchemy(app)
bcrypt = Bcrypt(app) bcrypt = Bcrypt(app)
mail = Mail(app)
swagger = Swagger(app, template_file='swagger/template.yaml') swagger = Swagger(app, template_file='swagger/template.yaml')
swagger.template['info']['version'] = app.config['APP_VERSION'] swagger.template['info']['version'] = app.config['APP_VERSION']
CORS(app) CORS(app)

View File

@ -0,0 +1,4 @@
<p>Welcome! Thanks for signing up. Please follow this link to activate your account:</p>
<p><a href="{{ confirm_url }}">{{ confirm_url }}</a></p>
<br>
<p>Cheers!</p>

View File

@ -27,6 +27,7 @@ CSRF_SESSION_KEY = "secret"
# Secret key for signing cookies # Secret key for signing cookies
SECRET_KEY = "?['Z(Z\x83Y \x06T\x12\x96<\xff\x12\xe0\x1b\xd1J\xe0\xd9ld" SECRET_KEY = "?['Z(Z\x83Y \x06T\x12\x96<\xff\x12\xe0\x1b\xd1J\xe0\xd9ld"
SECURITY_PASSWORD_SALT = "IyoZvOJb4feT3xKlYXyOJveHSIY4GDg6"
# MQTT configuration # MQTT configuration
MQTT_CLIENT_ID = 'final-iot-backend-server-' + os.environ['MQTT_CLIENT'] MQTT_CLIENT_ID = 'final-iot-backend-server-' + os.environ['MQTT_CLIENT']
@ -40,6 +41,20 @@ MQTT_REFRESH_TIME = 1.0 # refresh time in seconds
CELERY_BROKER_URL = os.environ['REDIS_URL'] CELERY_BROKER_URL = os.environ['REDIS_URL']
CELERY_RESULT_BACKEND = os.environ['REDIS_URL'] CELERY_RESULT_BACKEND = os.environ['REDIS_URL']
# Mailer config
MAIL_SERVER = 'smtp.googlemail.com'
MAIL_PORT = 465
MAIL_USE_TLS = False
MAIL_USE_SSL = True
MAIL_DEBUG = False
# gmail authentication
MAIL_USERNAME = os.environ['APP_MAIL_USERNAME']
MAIL_PASSWORD = os.environ['APP_MAIL_PASSWORD']
# mail accounts
MAIL_DEFAULT_SENDER = 'final.iot.backend.mailer@gmail.com'
# Flasgger config # Flasgger config
SWAGGER = { SWAGGER = {
'uiversion': 3 'uiversion': 3

View File

@ -0,0 +1,37 @@
"""empty message
Revision ID: ae43d7caad52
Revises: fd5cfe0f1c51
Create Date: 2018-10-10 21:01:06.071591
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ae43d7caad52'
down_revision = 'fd5cfe0f1c51'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
accounts = sa.table('accounts', sa.column('id', sa.Integer),
sa.column('confirmed', sa.BOOLEAN()))
op.execute(accounts.update().
values({'confirmed': True}))
op.alter_column('accounts', 'confirmed',
existing_type=sa.BOOLEAN(),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('accounts', 'confirmed',
existing_type=sa.BOOLEAN(),
nullable=True)
# ### end Alembic commands ###

View File

@ -0,0 +1,40 @@
"""empty message
Revision ID: fd5cfe0f1c51
Revises: 55611f1868bd
Create Date: 2018-10-08 23:07:48.054401
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fd5cfe0f1c51'
down_revision = '55611f1868bd'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('accounts', sa.Column('confirmed', sa.Boolean(), nullable=True))
op.add_column('accounts', sa.Column('confirmed_at', sa.DateTime(), nullable=True))
role = sa.table('roles', sa.column('id', sa.Integer),
sa.column('permissions', sa.ARRAY(sa.String)))
op.execute(role.update().where(role.c.id == op.inline_literal(1)).
values({'permissions':
['CREATE_DEVICE_TYPE', 'CREATE_ROLE',
'ASSIGN_ROLE',
'CREATE_DEVICE', 'CREATE_DASHBOARD',
'READ_DEVICE_TYPES', 'READ_ROLES']})
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('accounts', 'confirmed_at')
op.drop_column('accounts', 'confirmed')
# ### end Alembic commands ###

View File

@ -1,8 +1,10 @@
alembic==0.9.9 alembic==0.9.9
amqp==2.3.2 amqp==2.3.2
aniso8601==3.0.0 aniso8601==3.0.0
apispec==0.39.0
bcrypt==3.1.4 bcrypt==3.1.4
billiard==3.5.0.4 billiard==3.5.0.4
blinker==1.4
celery==4.2.0 celery==4.2.0
cffi==1.11.5 cffi==1.11.5
click==6.7 click==6.7
@ -11,11 +13,13 @@ Flask==1.0.2
Flask-API==1.0 Flask-API==1.0
Flask-Bcrypt==0.7.1 Flask-Bcrypt==0.7.1
Flask-Cors==3.0.4 Flask-Cors==3.0.4
Flask-Mail==0.9.1
Flask-Migrate==2.1.1 Flask-Migrate==2.1.1
Flask-MQTT==1.0.3 Flask-MQTT==1.0.3
Flask-RESTful==0.3.6 Flask-RESTful==0.3.6
Flask-Script==2.0.6 Flask-Script==2.0.6
Flask-SQLAlchemy==2.3.2 Flask-SQLAlchemy==2.3.2
functools32==3.2.3.post2
gunicorn==19.8.1 gunicorn==19.8.1
itsdangerous==0.24 itsdangerous==0.24
Jinja2==2.10 Jinja2==2.10